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.

Setup the base

Add up your Gemfile:

1
2
3
  bundle init
  bundle add bundler sinatra sqlite3 sinatra-activerecord puma rackup
  bundle add rerun --group development

Simple config.ru

1
2
3
4
  # config.ru
  require_relative 'app'

  run Sinatra::Application

Dockerize

A pretty standard Dockerfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  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"]

Rake tasks

And now a Rakefile, where we add runner tasks, docker tasks, as well as the activerecord migration tasks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
  # 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

Setup the app

Now an app.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
  # 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

Database Config

First create a directory for the database.yml file:

1
  mkdir config

Then setup sqlite3:

1
2
3
4
5
  development:
    adapter: sqlite3
    database: db/development.sqlite3
    pool: 5
    timeout: 5000
1
  rake db:create
1
Created database 'db/development.sqlite3'

Create a model

We'll begin by creating 2 directories, one that stores the model logic and the other which defines the routes

1
  mkdir routes models

Database

Let's add a model, for example post

1
  rake db:create_migration post
db/migrate/20230930213922_post.rb

Then we can add our fields to it to it

1
2
3
4
5
6
7
8
9
  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

1
rake db:migrate
1
2
3
4
== 20230930213922 Post: migrating =============================================
-- create_table(:posts)
   -> 0.0003s
== 20230930213922 Post: migrated (0.0003s) ====================================

And we can verify that it's there

1
2
3
  echo .schema posts | \
      sqlite3 db/development.sqlite3 | \
      fold -w 80 -s
1
2
3
CREATE TABLE IF NOT EXISTS "posts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT 
NULL, "name" varchar, "body" text, "created_at" datetime(6) NOT NULL, 
"updated_at" datetime(6) NOT NULL);

Code

First the model, where we tell it we need to have some required fields

1
2
3
4
  # 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  # 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

Testing it out

Start the server

1
  rake dev

Then we can test this out:

1
curl http://localhost:9292/posts
1
[]

Add a post

1
  curl http://localhost:9292/posts -d "name=First Post&body=This is the body" | jq .
1
2
3
4
5
6
7
{
  "id": 1,
  "name": "First Post",
  "body": "This is the body",
  "created_at": "2023-10-01T00:01:55.185Z",
  "updated_at": "2023-10-01T00:01:55.185Z"
}

Then we can see the results:

1
curl http://localhost:9292/posts | jq .
1
2
3
4
5
6
7
8
9
[
  {
    "id": 1,
    "name": "First Post",
    "body": "This is the body",
    "created_at": "2023-09-30T21:54:32.185Z",
    "updated_at": "2023-09-30T21:54:32.185Z"
  }
]

We can also try to add a post that's missing a required field:

1
  curl http://localhost:9292/posts -d "name=No body" | jq .
1
2
3
4
5
{
  "body": [
    "can't be blank"
  ]
}

Adding a password

Lets see how to add authentication.

1
  bundle add bcrypt --version '~> 3.1.7'

Create the migration and run it:

1
  rake db:create_migration account
db/migrate/20230930221648_account.rb
1
2
3
4
5
6
7
8
  class Account < ActiveRecord::Migration[7.0]
    def change
      create_table :accounts do |t|
        t.string :name
        t.string :password_digest
      end
    end
  end
1
rake db:migrate
1
2
== 20230930221648 Account: migrating ==========================================
== 20230930221648 Account: migrated (0.0000s) =================================

Add the model and the route

1
2
3
4
5
6
  # models/account.rb
  class Account < ActiveRecord::Base
    validates :name, uniqueness: true, presence: true

    has_secure_password
  end

Then lets add some routes for it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  # 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

Test account creation

Empty password confirmation

1
  curl http://localhost:9292/signup -d "name=will&password=password" | jq .
1
2
3
4
5
{
  "password_confirmation": [
    "doesn't match Password"
  ]
}

Not matched password confirmation

1
2
    curl http://localhost:9292/signup -d \
         "name=will&password=password&password_confirmation=pass" | jq .
1
2
3
4
5
{
  "password_confirmation": [
    "doesn't match Password"
  ]
}

Happy path

1
2
    curl http://localhost:9292/signup -d \
         "name=will&password=password&password_confirmation=password" | jq .
1
2
3
4
5
{
  "id": 1,
  "name": "will",
  "password_digest": "$2a$12$69I3OCj24aJ5QK.5CQfctO0ZmP4FYIi2BxzajvH2cKrYbMlEMYDRa"
}

Trying a double signup

1
2
    curl http://localhost:9292/signup -d \
         "name=will&password=password&password_confirmation=password" | jq .
1
2
3
4
5
{
  "name": [
    "has already been taken"
  ]
}

Testing login

Unauthenticated access

1
curl http://localhost:9292/private
1
{"access":"denied"}

Login and store the cookie in the jar!

1
2
3
    curl http://localhost:9292/login \
         -d 'name=will&password=password' \
         -c cookies.txt | jq .
1
2
3
4
5
6
7
{
  "success": {
    "id": 1,
    "name": "will",
    "password_digest": "$2a$12$69I3OCj24aJ5QK.5CQfctO0ZmP4FYIi2BxzajvH2cKrYbMlEMYDRa"
  }
}

Pass in the session cookie

1
curl -b cookies.txt http://localhost:9292/private | jq .
1
2
3
{
  "message": "This is a secret"
}

Create a model

Add a migration, for a table called for example poi

  rake db:create_migration poi

Here is an example migration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  :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:

1
rake db:migrate
1
2
3
4
== 20230930161023 Poi: migrating ==============================================
-- create_table(:pois)
   -> 0.0002s
== 20230930161023 Poi: migrated (0.0002s) =====================================

Check out the table

1
2
3
  echo .schema pois | \
      sqlite3 db.sqlite3 | \
      fold -w 80 -s
1
2
CREATE TABLE IF NOT EXISTS "pois" ("id" integer PRIMARY KEY AUTOINCREMENT NOT 
NULL, "name" varchar, "latitude" decimal(10,6), "longitude" decimal(10,6));

Previously

a good death

2023-08-23

Next

political implications

2023-10-01

labnotes

Previously

Indexing a hugo site using pagefind static all the way down

2023-07-23

Next

Running Google Gemma Locally in which i discover ollama

2024-02-27