#+tags[]: ruby, sinatra, activerecord
I want to build a very simple sinatra app, but I also want it to connect to the database. Here is the walk through for how to have a very base install, which includes migrations.
The final code is available at wschenk/sinatra-ar-template.
Add up your Gemfile
:
bundle init
bundle add bundler sinatra sqlite3 sinatra-activerecord puma rackup
bundle add rerun --group development
Simple config.ru
# config.ru
require_relative 'app'
run Sinatra::Application
A pretty standard Dockerfile
:
ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim as base
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential curl
RUN gem update --system --no-document && \
bundle config set --local without development
# Rack app lives here
WORKDIR /app
# Install application gems
COPY Gemfile* .
RUN bundle install --without development
RUN useradd ruby --home /app --shell /bin/bash
USER ruby:ruby
# Copy application code
COPY --chown=ruby:ruby . .
# Start the server
EXPOSE 3000
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "--port", "3000"]
And now a Rakefile
, where we add runner tasks, docker tasks, as well
as the activerecord
migration tasks.
# Rakefile
require "sinatra/activerecord/rake"
desc "Starts up a development server that autostarts when a file changes"
task :dev do
system "PORT=3000 rerun --ignore 'views/*,index.css' \"bundler exec rackup\""
end
desc "Builds a Docker image and runs"
task :build do
system "docker build . -t app && docker run -it --rm -p 3000:3000 app"
end
namespace :db do
task :load_config do
require "./app"
end
end
Now an app.rb
# app.rb
require 'sinatra'
require "sinatra/activerecord"
require_relative 'routes/posts.rb'
require_relative 'routes/account.rb'
# For cookies
use Rack::Session::Cookie, :key => 'rack.session',
:path => '/',
:secret => 'sosecret'
set :default_content_type, :json
get '/' do
{message:"Hello world."}
end
get '/up' do
{success:true}
end
First create a directory for the database.yml
file:
mkdir config
Then setup sqlite3
:
development:
adapter: sqlite3
database: db/development.sqlite3
pool: 5
timeout: 5000
rake db:create
We’ll begin by creating 2 directories, one that stores the model logic and the other which defines the routes
mkdir routes models
Let’s add a model, for example post
rake db:create_migration post
Then we can add our fields to it to it
class Post < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :name
t.text :body
t.timestamps
end
end
end
Then create the table
rake db:migrate
And we can verify that it’s there
echo .schema posts | \
sqlite3 db/development.sqlite3 | \
fold -w 80 -s
First the model, where we tell it we need to have some required fields
# models/post.rb
class Post < ActiveRecord::Base
validates_presence_of :name, :body
end
Then the routes itself, where we either return a list of all the posts or we create a post and return it.
# routes/posts.rb
require_relative '../models/post.rb'
get '/posts' do
Post.all.to_json
end
post '/posts' do
p = Post.new( name: params[:name], body: params[:body] )
if !p.save
p.errors.to_json
else
p.to_json
end
end
Start the server
rake dev
Then we can test this out:
curl http://localhost:9292/posts
Add a post
curl http://localhost:9292/posts -d "name=First Post&body=This is the body" | jq .
Then we can see the results:
curl http://localhost:9292/posts | jq .
We can also try to add a post that’s missing a required field:
curl http://localhost:9292/posts -d "name=No body" | jq .
Lets see how to add authentication.
bundle add bcrypt --version '~> 3.1.7'
Create the migration and run it:
rake db:create_migration account
class Account < ActiveRecord::Migration[7.0]
def change
create_table :accounts do |t|
t.string :name
t.string :password_digest
end
end
end
rake db:migrate
# models/account.rb
class Account < ActiveRecord::Base
validates :name, uniqueness: true, presence: true
has_secure_password
end
Then lets add some routes for it:
# routes/account.rb
require_relative '../models/account.rb'
post '/signup' do
account = Account.new(
name: params[:name],
password: params[:password],
password_confirmation: params[:password_confirmation] || '')
if account.save
account.to_json
else
account.errors.to_json
end
end
post '/login' do
account = Account.find_by( name: params[:name])&.authenticate(params[:password])
if account
session[:account_id] = account.id
puts "setting session #{session[:account_id]}"
end
{ success: account }.to_json
end
get '/private' do
auth_check do
{ message: "This is a secret" }.to_json
end
end
def auth_check
unless session[:account_id]
return { access: :denied }.to_json
else
return yield
end
end
Empty password confirmation
curl http://localhost:9292/signup -d "name=will&password=password" | jq .
Not matched password confirmation
curl http://localhost:9292/signup -d \
"name=will&password=password&password_confirmation=pass" | jq .
Happy path
curl http://localhost:9292/signup -d \
"name=will&password=password&password_confirmation=password" | jq .
Trying a double signup
curl http://localhost:9292/signup -d \
"name=will&password=password&password_confirmation=password" | jq .
Unauthenticated access
curl http://localhost:9292/private
Login and store the cookie in the jar!
curl http://localhost:9292/login \
-d 'name=will&password=password' \
-c cookies.txt | jq .
Pass in the session cookie
curl -b cookies.txt http://localhost:9292/private | jq .
Add a migration, for a table called for example poi
rake db:create_migration poi
Here is an example migration
:tangle db/migrate/20230930161023_poi.rb
class Poi < ActiveRecord::Migration[7.0]
def change
create_table :pois do |t|
t.string :name
t.decimal :latitude, precision: 10, scale: 6
t.decimal :longitude, precision: 10, scale: 6
end
end
end
Then we can run it:
rake db:migrate
Check out the table
echo .schema pois | \
sqlite3 db.sqlite3 | \
fold -w 80 -s