As I mentioned a few times, the app should track bikes. So if I think about screens, endpoints, and data bike is the first and main thing. Users, authentication, etc, this stuff will come in the future. They aren’t necessary for the app. Same story when you build client products. You should think about where is the business and where is money, what should be delivered. Ofc users often come first to mind. But let’s say we have a mobile or desktop app. The device ID is enough to recognize who tries to get/put data so we can focus on the more important stuff. Even if you have some kind of e-commerce platform, money is credit card, not the user. So you have to implement a payment system, not a user profile first.

Now, let’s return to my app. I began with a small Rack app, and you may not be familiar with the fact that in a Rack app, it’s possible to mount another Rack app, much like a matryoshka doll. In this case, I created a separate Rack app specifically for managing bikes.

module Bikes
  class App
    def call(env)
      [200, { 'content-type' => 'application/json' }, [{ message: 'Hello, World from Bike app!' }.to_json]]
    end
  end
end

and in main app I have to call this new application

require 'rack'
require 'json'
require './bikes/app'
class MyApp
  def call(env)
    req = Rack::Request.new(env)
    if req.path.start_with?('/bikes')
      Bikes::App.new.call(env)
    else
      case req.path
      when '/'
        [200, { 'content-type' => 'text/html' }, [File.read('frontend/index.html')]]
      when '/hello'
        [200, { 'content-type' => 'application/json' }, [{ message: 'Hello, World!' }.to_json]]
      else
        [404, { 'content-type' => 'text/plain' }, ['Not Found']]
      end
    end
  end
end

From tests perspective, I only have to check if proper class is run

describe 'GET /bikes' do
  it 'calls Bikes::App' do
    expect_any_instance_of(Bikes::App).to receive(:call).and_call_original
    get '/bikes'
  end
end

Rest of the tests, related to bikes would be in separate spec files.

The title of post include CRUD (create, read, update, delete). I need to create this kind of action in my new Rack app.

require 'rack'

module Bikes
  class App
    def call(env)
      request = Rack::Request.new(env)

      case request.path_info
      when '/'
        handle_index(request)
      when '/bikes'
        case request.request_method
        when 'GET'
          handle_index(request)
        when 'POST'
          handle_create(request)
        else
          [405, { 'Content-Type' => 'text/plain' }, ['Method Not Allowed']]
        end
      when %r{/bikes/(\d+)}
        bike_id = $1.to_i
        case request.request_method
        when 'GET'
          handle_read(request, bike_id)
        when 'PUT'
          handle_update(request, bike_id)
        when 'DELETE'
          handle_delete(request, bike_id)
        else
          [405, { 'Content-Type' => 'text/plain' }, ['Method Not Allowed']]
        end
      else
        [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
      end
    end

    private

    def handle_index(request)
      [200, { 'Content-Type' => 'text/plain' }, ['Index']]
    end

    def handle_create(request)
      [201, { 'Content-Type' => 'text/plain' }, ['Create']]
    end

    def handle_read(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Read with ID #{bike_id}"]]
    end

    def handle_update(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Update with ID #{bike_id}"]]
    end

    def handle_delete(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Delete with ID #{bike_id}"]]
    end
  end
end

Test for them

require 'rack/test'
require_relative './../../bikes/app'

RSpec.describe Bikes::App do
  include Rack::Test::Methods

  def app
    Bikes::App.new
  end

  describe 'GET /' do
    it 'returns the index' do
      get '/'
      expect(last_response.status).to eq(200)
      expect(last_response.body).to eq('Index')
    end
  end

  describe 'GET /:id' do
    it 'reads a bike with the given id' do
      get 'bikes/2'
      expect(last_response.status).to eq(200)
      expect(last_response.body).to eq('Read with ID 2')
    end
  end

  describe 'PUT /:id' do
    it 'updates a bike with the given id' do
      put 'bikes/2'
      expect(last_response.status).to eq(200)
      expect(last_response.body).to eq('Update with ID 2')
    end
  end

  describe 'DELETE /:id' do
    it 'deletes a bike with the given id' do
      delete 'bikes/2'
      expect(last_response.status).to eq(200)
      expect(last_response.body).to eq('Delete with ID 2')
    end
  end

  describe 'Non-existent route' do
    it 'returns 404 Not Found' do
      get '/non-existent'
      expect(last_response.status).to eq(404)
      expect(last_response.body).to eq('Not Found')
    end
  end

  describe 'Unsupported method' do
    it 'returns 405 Method Not Allowed' do
      post 'bikes/2'
      expect(last_response.status).to eq(405)
      expect(last_response.body).to eq('Method Not Allowed')
    end
  end
end

Looks terrible, and it is a kind of mix of routers and controllers. In general, we could create a whole, big application in one file, but reading and maintaining it would be a nightmare. But sometimes it happens https://twitter.com/levelsio/status/938707166508154880 For clean and reading purposes I’m going to separate routing and controlling code.

module Bikes
  class Controller

    def index(request)
      [200, { 'Content-Type' => 'text/plain' }, ['Index']]
    end

    def create(request)
      [201, { 'Content-Type' => 'text/plain' }, ['Create']]
    end

    def read(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Read with ID #{bike_id}"]]
    end

    def update(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Update with ID #{bike_id}"]]
    end

    def delete(request, bike_id)
      [200, { 'Content-Type' => 'text/plain' }, ["Delete with ID #{bike_id}"]]
    end
  end
end

I can do this even more deeply, and create one file per action. But right now is enough.

Currently, I have decided to store all the data in a CSV file, as I would prefer not to delve into the topic of databases at this stage. There are a lot of options, and I would create a separate post about it. Even if I chose one of SQL database, e.g sqlite or postgres, as well as deciding between raw SQL or ORM. And with ORMs, there are few to choose from. There is no reason to have the database, yet.

module Bikes
  class Controller

    def initialize
      @database = 'bikes.csv'
    end

    def index(request)
      bikes = read_database

      [200, { 'Content-Type' => 'application/json' }, [bikes.to_json]]
    end

    def create(request)
      bike_data = JSON.parse(request.body.read)
      bikes = read_database
      bikes << bike_data
      write_database(bikes)
      [201, { 'Content-Type' => 'text/plain' }, ['Create']]
    end

    def read(request, bike_id)
      bikes = read_database
      bike = bikes.find { |b| b['id'] == bike_id }
      if bike
        [200, { 'Content-Type' => 'application/json' }, [bike.to_json]]
      else
        [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
      end
    end

    def update(request, bike_id)
      bike_data = JSON.parse(request.body.read)
      bikes = read_database
      index = bikes.find_index { |b| b['id'] == bike_id }
      if index
        bikes[index] = bike_data
        write_database(bikes)
        [200, { 'Content-Type' => 'text/plain' }, ["Update with ID #{bike_id}"]]
      else
        [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
      end
    end

    def delete(request, bike_id)
      bikes = read_database
      index = bikes.find_index { |b| b['id'] == bike_id }
      if index
        bikes.delete_at(index)
        write_database(bikes)
        [200, { 'Content-Type' => 'text/plain' }, ["Delete with ID #{bike_id}"]]
      else
        [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
      end
    end
  end

  private

  def read_database
    CSV.read(@database, headers: true, header_converters: :symbol).map(&:to_h)
  end

  def write_database(data)
    CSV.open(@database, 'w', write_headers: true, headers: data.first&.keys) do |csv|
      data.each { |row| csv << row.values }
    end
  end
end

With these tests, even I will change database more less they should pass.

require 'csv'
require 'json'
require_relative './../../bikes/controller'

RSpec.describe Bikes::Controller do
  let(:controller) { Bikes::Controller.new }

  before(:each) do
    allow(CSV).to receive(:read).and_return([])
    allow(CSV).to receive(:open)
  end

  describe '#index' do
    it 'returns the list of bikes' do
      allow(controller).to receive(:read_database).and_return([{ id: 1, name: 'Mountain Bike' }])

      response = controller.index(nil)

      expect(response).to eq([200, { 'Content-Type' => 'application/json' }, [[{ id: 1, name: 'Mountain Bike' }].to_json]])
    end
  end

  describe '#create' do
    it 'creates a new bike' do
      allow(controller).to receive(:read_database).and_return([])
      allow(controller).to receive(:write_database)

      request = double('request', body: double('body', read: { id: 1, name: 'Mountain Bike' }.to_json))

      response = controller.create(request)

      expect(response).to eq([201, { 'Content-Type' => 'text/plain' }, ['Create']])
      expect(controller).to have_received(:write_database).with([{ 'id' => 1, 'name' => 'Mountain Bike' }])
    end
  end

  describe '#read' do
    it 'returns the bike with the given id if it exists' do
      allow(controller).to receive(:read_database).and_return([{ 'id' => 1, 'name' => 'Mountain Bike' }])

      response = controller.read(nil, 1)

      expect(response).to eq([200, { 'Content-Type' => 'application/json' }, [{ id: 1, name: 'Mountain Bike' }.to_json]])
    end

    it 'returns 404 Not Found if the bike does not exist' do
      allow(controller).to receive(:read_database).and_return([])

      response = controller.read(nil, 1)

      expect(response).to eq([404, { 'Content-Type' => 'text/plain' }, ['Not Found']])
    end
  end

  describe '#update' do
    it 'updates the bike with the given id if it exists' do
      allow(controller).to receive(:read_database).and_return([{ 'id' => 1, 'name' => 'Mountain Bike' }])
      allow(controller).to receive(:write_database)

      request = double('request', body: double('body', read: { name: 'Road Bike' }.to_json))

      response = controller.update(request, 1)

      expect(response).to eq([200, { 'Content-Type' => 'text/plain' }, ['Update with ID 1']])
      expect(controller).to have_received(:write_database).with([{ 'name' => 'Road Bike' }])
    end

    it 'returns 404 Not Found if the bike does not exist' do
      allow(controller).to receive(:read_database).and_return([])

      request = double('request', body: double('body', read: { name: 'Road Bike' }.to_json))

      response = controller.update(request, 1)

      expect(response).to eq([404, { 'Content-Type' => 'text/plain' }, ['Not Found']])
    end
  end

  describe '#delete' do
    it 'deletes the bike with the given id if it exists' do
      allow(controller).to receive(:read_database).and_return([{ 'id' => 1, 'name' => 'Mountain Bike' }])
      allow(controller).to receive(:write_database)

      response = controller.delete(nil, 1)

      expect(response).to eq([200, { 'Content-Type' => 'text/plain' }, ['Delete with ID 1']])
      expect(controller).to have_received(:write_database).with([])
    end

    it 'returns 404 Not Found if the bike does not exist' do
      allow(controller).to receive(:read_database).and_return([])

      response = controller.delete(nil, 1)

      expect(response).to eq([404, { 'Content-Type' => 'text/plain' }, ['Not Found']])
    end
  end
end

I need to fix my request tests.

require 'rack/test'
require 'json'

require_relative './../../bikes/app'

RSpec.describe Bikes::App do
  include Rack::Test::Methods

  def app
    Bikes::App.new
  end

  before do
    bikes_data = [{ id: 1, name: 'Mountain Bike' }]
    CSV.open('bikes.csv', 'w', write_headers: true, headers: bikes_data.first.keys) do |csv|
      bikes_data.each { |bike| csv << bike.values }
    end
  end

  let(:bike_data) { { id: 1, name: 'Mountain Bike' }.to_json }

  it 'creates a new bike' do
    post '/bikes', bike_data

    expect(last_response.status).to eq(201)
    expect(last_response.body).to eq('Create')
  end

  it 'reads a bike with the given id' do
    get '/bikes/1'

    expect(last_response.status).to eq(200)
    expect(last_response.body).to eq({ id: '1', name: 'Mountain Bike' }.to_json)
  end

  it 'updates a bike with the given id' do
    put '/bikes/1', bike_data

    expect(last_response.status).to eq(200)
    expect(last_response.body).to eq('Update with ID 1')
  end

  it 'deletes a bike with the given id' do
    delete '/bikes/1'

    expect(last_response.status).to eq(200)
    expect(last_response.body).to eq('Delete with ID 1')
  end
end

Everything works fine and I can check API manually.

curl -X POST http://localhost:9292/bikes -H 'Content-Type: application/json' -d '{"id":"1","name":"Mountain Bike"}'

In the beginning I thought maybe API is enough and I could create a whole flow in Postman. But it’s nice to have some front end.

<!DOCTYPE html>
<html>
<head>
  <title>Bikes Frontend</title>
</head>
<body>
  <h1>Bikes Frontend</h1>
  <div id="bikesList"></div>

  <form id="bikeForm">
    <input type="text" id="nameInput" placeholder="Bike name" required>
    <button type="submit">Create Bike</button>
  </form>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const bikesList = document.getElementById('bikesList');
      const bikeForm = document.getElementById('bikeForm');
      const nameInput = document.getElementById('nameInput');

      // Fetch and display all bikes
      const fetchBikes = () => {
        fetch('/bikes')
          .then(response => response.json())
          .then(data => {
            bikesList.innerHTML = '';
            data.forEach(bike => {
              const bikeItem = document.createElement('div');
              bikeItem.innerHTML = `
                <p>ID: ${bike.id}</p>
                <p>Name: ${bike.name}</p>
                <button onclick="updateBike(${bike.id})">Update</button>
                <button onclick="deleteBike(${bike.id})">Delete</button>
              `;
              bikesList.appendChild(bikeItem);
            });
          })
          .catch(error => {
            console.error('Error:', error);
            bikesList.textContent = 'Error retrieving bikes.';
          });
      };

      // Create a new bike
      bikeForm.addEventListener('submit', event => {
        event.preventDefault();
        const bikeName = nameInput.value;

        fetch('/bikes', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ name: bikeName })
        })
          .then(response => {
            if (response.ok) {
              nameInput.value = '';
              fetchBikes();
            } else {
              throw new Error('Failed to create bike.');
            }
          })
          .catch(error => {
            console.error('Error:', error);
            bikesList.textContent = 'Error creating bike.';
          });
      });

      // Update a bike
      window.updateBike = bikeId => {
        const newBikeName = prompt('Enter the new name for the bike:');
        if (newBikeName) {
          fetch(`/bikes/${bikeId}`, {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ name: newBikeName })
          })
            .then(response => {
              if (response.ok) {
                fetchBikes();
              } else {
                throw new Error('Failed to update bike.');
              }
            })
            .catch(error => {
              console.error('Error:', error);
              bikesList.textContent = 'Error updating bike.';
            });
        }
      };

      // Delete a bike
      window.deleteBike = bikeId => {
        if (confirm('Are you sure you want to delete this bike?')) {
          fetch(`/bikes/${bikeId}`, {
            method: 'DELETE'
          })
            .then(response => {
              if (response.ok) {
                fetchBikes();
              } else {
                throw new Error('Failed to delete bike.');
              }
            })
            .catch(error => {
              console.error('Error:', error);
              bikesList.textContent = 'Error deleting bike.';
            });
        }
      };

      // Fetch and display bikes when the page loads
      fetchBikes();
    });
  </script>
</body>
</html>

In future, even if I change database, or logic in controller, frontend should stay the same. OFC if I not change something in the meantime.

The whole code is available on Github

I demonstrated the process of building a basic CRUD (Create, Read, Update, Delete) functionality for a bike tracking application using Ruby and Rack. I started with a simple Rack app and gradually expanded it to handle different routes and HTTP methods. I show you the concept of separating routing and controller logic for better code organization and maintainability. By separating the responsibilities of handling requests and managing data, with more modular code structure. To simulate data storage, we utilized a CSV file as a simple data repository. Although this approach is suitable for a small-scale application, it’s worth noting that in a real-world scenario, other options like databases or ORMs might be more appropriate. Building a CRUD functionality is a fundamental step in developing web applications. As you continue to enhance your application, you can expand upon this foundation by incorporating additional features such as user authentication, validation, and more complex data storage solutions.

Remember, the key to building successful applications lies in considering the business requirements, delivering value to the users, and continuously iterating and improving based on feedback. With the knowledge gained from this article, you’re now equipped to take the next steps in building your bike tracking application or explore other exciting areas of web development. Happy coding!