Igor's Techno Club

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)

2. Flask (Python)

3. Sinatra (Ruby)

4. Slim (PHP)

5. Spark (Java)

6. FastAPI (Python)

7. Gin (Go)

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.

#api #flask #flyio #python #rest