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!