Building a simple REST API application in 2024: my story
I don't often do web development, but every time I need to create a simple REST application, it always frustrates me: which language to choose, which framework, whether I need an ORM, and eventually how to deploy it and where. Because of this, I always try to avoid building a REST application myself and instead rely on an OOTB solution (Bearblog is the best, tbh).
But this time, I figured out that the time has come to look around and see what has changed since Rails was dominating the field and what is the fastest way to create the simplest REST application nowadays.
I came up with a simple requirement: build a CRUD application as fast as possible.
After 10 minutes of searching, I had already put together in my mind the stack: SQLite for persistence, any programming language, and a framework that will give me some abstraction around the DB layer and routing. For hosting, I chose Fly.io for its documentation and conservative pricing.
Choosing A Tech Stack
Choosing the DB was easy; actually, I didn't need a DB at all. I could have written records into a regular txt file, but in my latest experience, working with SQLite was a pleasure: it's basically one file that doesn't require any configuration (no installation, configuration in an OS, etc.), but you work with it as with any other feature-full DB.
When I was looking for the framework, the first thing I wanted to build with was Ruby on Rails, because I had previously had some experience working with it. But after I scaffolded the initial application rails new sample-api --api -d sqlite3
, I was literally paralyzed with how many files it generated:
[~/side/sample-api]: find . -type f | wc -l
76
Just looking at that number of files in the empty project put me into depression right away: I wanted to add 3-4 endpoints max without adding dozens of files.
This put me in a quandary about what framework could liberate me from dealing with HTTP directly and without complicating other stuff.
I asked around ChatGPT which frameworks it knew about with the aforementioned requirements, and it answered with few examples.
Frameworks to consider
For building a REST API, developers often look for frameworks that are lightweight, easy to use, and have minimal overhead. Here are some of the most minimal frameworks across different programming languages:
1. Express.js (Node.js)
- Description: Express is a fast, unopinionated, minimalist web framework for Node.js. It is widely used due to its simplicity and the vast middleware ecosystem available via npm.
- Use Case: Ideal for building APIs quickly with a huge community and many available resources.
2. Flask (Python)
- Description: Flask is a micro web framework for Python. It is designed to be simple and easy to use, allowing you to set up an API quickly with minimal setup.
- Use Case: Great for small to medium applications, prototyping, and learning the fundamentals of web development.
3. Sinatra (Ruby)
- Description: Sinatra is a DSL (domain-specific language) for quickly creating web applications in Ruby with minimal effort.
- Use Case: Best suited for small applications or when the overhead of a full framework like Rails is unnecessary.
4. Slim (PHP)
- Description: Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs.
- Use Case: Excellent for small web applications or APIs where minimalism and performance are key.
5. Spark (Java)
- Description: Spark is a micro framework for quickly creating web applications in Java with minimal setup.
- Use Case: Useful for Java developers looking to build simple web services without the complexity of larger frameworks like Spring.
6. FastAPI (Python)
- Description: Although not as minimal as Flask, FastAPI is a modern, fast (high-performance) web framework for building APIs with Python 3.7+ based on standard Python type hints.
- Use Case: Excellent for building APIs that require high performance and are easy to scale, with automatic interactive API documentation.
7. Gin (Go)
- Description: Gin is an HTTP web framework written in Go (Golang). It features a Martini-like API with much better performance, up to 40 times faster, thanks to httprouter.
- Use Case: Great for Go developers who need a performant API with a minimalistic setup.
Flask
From that list, I chose Flask
for the main reason that it was supported by Fly.io and even there were some examples written in Flask.
The beauty of Flask lies in its simplicity. Even if you are not familiar with Python (I am not a Python developer either), you can get right away what this code is doing:
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
@app.route('/<name>')
def hello(name=None):
return render_template('hello.html', name=name)
and for the REST application, it will be even easier, because you don't need to render anything:
@app.route('/users/<user_id>', methods=['GET'])
def get_user(user_id):
user details = ...
return jsonify({
"message": "User details by user_id",
"user": user details
}), 200
For the next hour, I was writing the simplest CRUD application with basic authentication. Records were stored in a SQLite DB, and to do so, I used SQLAlchemy
, which provides a simple ORM to save/read from the table (worth noting that the syntax is very similar to the ActiveRecord one).
The Result
from flask import Flask, request, jsonify, abort
from flask_sqlalchemy import SQLAlchemy
from functools import wraps
import base64
import re
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///my.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
def extract_auth_credentials(auth_header):
"""
Extracts and decodes the Authorization header credentials.
Returns:
tuple: A tuple containing the user_id and password.
"""
if not auth_header or not auth_header.startswith('Basic '):
return None, None
encoded_credentials = auth_header.split(' ', 1)[1]
try:
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
user_id, password = decoded_credentials.split(':', 1)
return user_id, password
except (TypeError, ValueError, IndexError):
return None, None
# Authentication decorator
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header:
return jsonify({"message": "Authentication Failed"}), 401
user_id, password = extract_auth_credentials(auth_header)
if not user_id or not password:
return jsonify({"message": "Authentication Failed"}), 401
user = User.query.filter_by(user_id=user_id, password=password).first()
if not user:
return jsonify({"message": "Authentication Failed"}), 401
return f(*args, **kwargs)
return decorated
# Define the User model
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String(20), unique=True, nullable=False)
password = db.Column(db.String(20), nullable=False)
nickname = db.Column(db.String(50), nullable=True)
comment = db.Column(db.String(200), nullable=True)
def to_dict(self):
return {
'user_id': self.user_id,
'nickname': self.nickname
}
# Create the database tables
with app.app_context():
db.create_all()
# Helper function for validation
def is_valid_user_id(user_id):
return bool(re.match(r"^[a-zA-Z0-9]{6,20}$", user_id))
# Helper function for validation
def is_valid_password(password):
return bool(re.match(r"^[ -~]{8,20}$", password))
@app.route('/signup', methods=['POST'])
def signup():
data = request.json
user_id = data.get('user_id')
password = data.get('password')
# Validation
if not user_id or not password:
return jsonify({"message": "Account creation failed", "cause": "required user_id and password"}), 400
if not is_valid_user_id(user_id):
return jsonify({"message": "Account creation failed", "cause": "invalid user_id format"}), 400
if not is_valid_password(password):
return jsonify({"message": "Account creation failed", "cause": "invalid password format"}), 400
# Check if user_id is already taken
if User.query.filter_by(user_id=user_id).first():
return jsonify({"message": "Account creation failed", "cause": "already same user_id is used"}), 400
# Create new user
new_user = User(user_id=user_id, password=password, nickname=user_id)
db.session.add(new_user)
db.session.commit()
return jsonify({"message": "Account successfully created", "user": new_user.to_dict()}), 200
@app.route('/users/<user_id>', methods=['GET'])
@require_auth
def get_user(user_id):
# The user whose details are being requested
user = User.query.filter_by(user_id=user_id).first()
# Check if the requested user exists
if not user:
return jsonify({"message": "No User found"}), 404
# If nickname is not set, use user_id as nickname
nickname = user.nickname if user.nickname else user.user_id
user_details = {
"user_id": user.user_id,
"nickname": nickname
}
# Include the comment only if it is set
if user.comment:
user_details["comment"] = user.comment
return jsonify({
"message": "User details by user_id",
"user": user_details
}), 200
@app.route('/users/<user_id>', methods=['PATCH'])
@require_auth
def update_user(user_id):
auth_user, _ = extract_auth_credentials(request.headers.get('Authorization'))
if auth_user != user_id:
# If the authenticated user is trying to update a different user's information
return jsonify({"message": "No Permission for Update"}), 403
user = User.query.filter_by(user_id=user_id).first()
if not user:
return jsonify({"message": "No User found"}), 404
data = request.json
if not data:
return jsonify({"message": "User updation failed", "cause": "required nickname or comment"}), 400
nickname = data.get('nickname')
comment = data.get('comment')
if nickname is None and comment is None:
return jsonify({"message": "User updation failed", "cause": "required nickname or comment"}), 400
if nickname == '':
user.nickname = user.user_id
elif nickname:
user.nickname = nickname
if comment == '':
user.comment = None
elif comment is not None:
user.comment = comment
db.session.commit()
response_data = {"nickname": user.nickname}
if user.comment is not None:
response_data["comment"] = user.comment
return jsonify({"message": "User successfully updated", "user": response_data}), 200
@app.route('/close', methods=['POST'])
@require_auth
def delete_account():
auth_user, _ = extract_auth_credentials(request.headers.get('Authorization'))
# Find the user with the given user_id and password in the database
user = User.query.filter_by(user_id=auth_user).first()
# If the user is not found, authentication has failed
if user is None:
return jsonify({"message": "Authentication Failed"}), 401
# Delete the user from the database
db.session.delete(user)
db.session.commit()
return jsonify({"message": "Account and user successfully removed"}), 200
Deployment
Usually, deployment is the most overlooked step, which takes much more time than you initially thought it would. But not with Fly.io.
To be able to deploy, you will need a paid account (I paid $5) and a configuration file located in the same project folder:
app = 'random-id'
primary_region = 'ams'
[build]
builder = 'paketobuildpacks/builder:full' # changed to full
[env]
PORT = '8080'
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
After that, I could deploy the application just by running fly launch
. The only caveat was that in the example, the base builder paketobuildpacks/builder:base
was used, which doesn't have SQLite binaries, so I changed it to the full
one.
Emotions
With Flask and Fly.io, it was really fun building a REST application without any major hiccups. The tools should serve the purpose, not the other way around, and in my experience, for a simple REST application, SQLite, Flask, and Fly.io are the perfect choices.