Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ reviews:
- "!vendor/**"
- "!storage/**"
- "!Gemfile.lock"
- "!**.keep"
- "!**/.keep"

path_instructions:
# ── Models ──────────────────────────────────────────────────────────
Expand Down
48 changes: 34 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ name: "Release Telurify-API"
on:
push:
tags:
- "v*.*.*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"

concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false

permissions:
contents: write
packages: write
contents: read

jobs:
test:
name: Quality Checks
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
image: postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
ports:
- "5432:5432"
env:
Expand All @@ -33,10 +37,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Install Ruby and gems
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@3612d7c07b4618a385f95b9d36a3e5e40e69a04a # v1.222.0
with:
bundler-cache: true

Expand All @@ -56,29 +62,42 @@ jobs:
name: Build & Publish Release
needs: test
runs-on: ubuntu-latest
# Requires contents: write to create GitHub releases and packages: write to push Docker images to GHCR
permissions:
contents: write
packages: write
Comment thread
coderabbitai[bot] marked this conversation as resolved.

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Validate SemVer tag
run: |
if ! [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid SemVer tag: ${{ github.ref_name }}"
exit 1
fi

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@9780b0c442fbb11e7d0cfd199dd156f3e0d7b114 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d576c707f25b1f504f739 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}

- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@471d1dc4e07e5cd41321f5301416d09a7fed8192 # v6.15.0
with:
context: .
file: ./Dockerfile
Expand All @@ -87,6 +106,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}

- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.ref_name }}
run: gh release create "$RELEASE_TAG" --generate-notes
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ gem 'csv'
gem 'will_paginate', '~> 4.0'

gem 'rack-cors', '~> 2.0', '>= 2.0.2'

gem 'rack-attack', '~> 6.7'
gem 'redis', '>= 4.0.1'
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ GEM
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-session (2.1.2)
Expand Down Expand Up @@ -227,6 +229,10 @@ GEM
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (6.0.0)
redis-client (= 0.30.1)
redis-client (0.30.1)
connection_pool
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
Expand Down Expand Up @@ -281,8 +287,10 @@ DEPENDENCIES
minitest (~> 5.22)
pg (~> 1.1)
puma (>= 5.0)
rack-attack (~> 6.7)
rack-cors (~> 2.0, >= 2.0.2)
rails (~> 7.2.3, >= 7.2.3.2)
redis (>= 4.0.1)
rubocop (~> 1)
tzinfo-data
will_paginate (~> 4.0)
Expand Down
39 changes: 39 additions & 0 deletions app/controllers/sismos_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ def index
render json: serialized_sismos
end

def stats
stats_data = calculate_stats
render json: serialize_stats(stats_data)
end

private

def filter_sismos
Expand Down Expand Up @@ -172,4 +177,38 @@ def serialize_sismos(sismos)
}
}
end

def calculate_stats
max_sismo = Sismo.where.not(mag: nil).order(mag: :desc).first

{
total_sismos: Sismo.count,
last_24h_count: Sismo.where('created_at >= ?', 24.hours.ago).count,
tsunami_count: Sismo.by_tsunami('true').count,
max_magnitude: serialize_max_magnitude(max_sismo),
by_mag_type: Sismo.group(:magType).count
}
end

def serialize_max_magnitude(sismo)
return nil unless sismo

{
id: sismo.id,
title: sismo.title,
magnitude: sismo.mag,
place: sismo.place,
time: sismo.created_at.to_s
}
end

def serialize_stats(stats_data)
{
data: {
id: 'stats',
type: 'stats',
attributes: stats_data
}
}
Comment thread
Euler-B marked this conversation as resolved.
end
end
3 changes: 3 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class Application < Rails::Application
end
end

# Enable Rack::Attack middleware for rate limiting and throttling
config.middleware.use Rack::Attack

# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
Expand Down
48 changes: 48 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
module Rack
class Attack
# Cache store for rate limit counters.
# Uses RedisCacheStore if RACK_ATTACK_REDIS_URL or REDIS_URL is present.
# Falls back gracefully to MemoryStore if Redis is not configured yet.
redis_url = ENV['RACK_ATTACK_REDIS_URL'].presence || ENV['REDIS_URL'].presence

Rack::Attack.cache.store = if redis_url
ActiveSupport::Cache::RedisCacheStore.new(
url: redis_url,
namespace: 'rack_attack'
)
elsif Rails.env.production?
raise 'RACK_ATTACK_REDIS_URL or REDIS_URL is required in production'
else
ActiveSupport::Cache::MemoryStore.new
Comment thread
Euler-B marked this conversation as resolved.
end

# 1. Throttle all requests by IP (60 req/min)
throttle('req/ip', limit: 60, period: 1.minute) do |req|
req.ip unless req.path.start_with?('/assets')
end

# 2. Throttle POST reports endpoint by IP (5 req/min) to prevent spam
throttle('reports/ip', limit: 5, period: 1.minute) do |req|
req.ip if req.path_info.match?(%r{\A/v1/sismos/\d+/reports(?:\.[^/]+)?\z}) && req.post?
end

# 3. Custom Response for Throttled Requests (HTTP 429)
self.throttled_responder = lambda do |request|
match_data = request.env['rack.attack.match_data'] || {}
now = match_data[:epoch_time] || Time.now.to_i
period = match_data[:period] || 60
retry_after = period - (now % period)

headers = {
'Content-Type' => 'application/json; charset=utf-8',
'Retry-After' => retry_after.to_s
}

body = {
error: "Rate limit exceeded. Try again in #{retry_after} seconds."
}.to_json

[429, headers, [body]]
end
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Rails.application.routes.draw do
scope '/v1' do
resources :sismos, only: [:index] do
get 'stats', on: :collection
resources :reports, only: [:create]
end
end
Expand Down
17 changes: 17 additions & 0 deletions test/controllers/sismos_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,21 @@ class SismosControllerTest < ActionDispatch::IntegrationTest
json = JSON.parse(response.body)
assert_equal 'Invalid value for filter: mag_max', json['error']
end

# ── Stats endpoint ────────────────────────────────────────────────
test 'should get stats with calculated metrics' do
get stats_sismos_url
assert_response :success

json = JSON.parse(response.body)
assert_equal 'stats', json['data']['id']
assert_equal 'stats', json['data']['type']

attrs = json['data']['attributes']
assert_equal Sismo.count, attrs['total_sismos']
assert_equal Sismo.where('created_at >= ?', 24.hours.ago).count, attrs['last_24h_count']
assert_equal 1, attrs['tsunami_count']
assert_equal 7.8, attrs['max_magnitude']['magnitude']
assert attrs['by_mag_type'].key?('ml')
Comment thread
Euler-B marked this conversation as resolved.
end
end
65 changes: 65 additions & 0 deletions test/integration/rate_limiting_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require 'test_helper'

class RateLimitingTest < ActionDispatch::IntegrationTest
setup do
Rack::Attack.cache.store.clear
end

test 'allows requests under the rate limit' do
get sismos_url
assert_response :success
end

test 'returns 429 too many requests when general rate limit is exceeded' do
travel_to Time.utc(2026, 8, 4, 12, 0, 0) do
60.times do
get sismos_url
assert_response :success
end

get sismos_url
assert_response :too_many_requests

json = JSON.parse(response.body)
assert json.key?('error')
assert_includes json['error'], 'Rate limit exceeded'
assert response.headers.key?('Retry-After')
end
end

test 'returns 429 when POST report rate limit is exceeded' do
sismo = sismos(:one)

travel_to Time.utc(2026, 8, 4, 12, 0, 0) do
5.times do
post sismo_reports_url(sismo), params: { felt: true, intensity: 'moderate' }, as: :json
assert_response :created
end

post sismo_reports_url(sismo), params: { felt: true, intensity: 'moderate' }, as: :json
assert_response :too_many_requests

json = JSON.parse(response.body)
assert json.key?('error')
assert_includes json['error'], 'Rate limit exceeded'
end
end

test 'returns 429 when POST report rate limit is exceeded on format-suffixed path' do
sismo = sismos(:one)

travel_to Time.utc(2026, 8, 4, 12, 0, 0) do
5.times do
post "#{sismo_reports_path(sismo)}.json", params: { felt: true, intensity: 'moderate' }, as: :json
assert_response :created
end

post "#{sismo_reports_path(sismo)}.json", params: { felt: true, intensity: 'moderate' }, as: :json
assert_response :too_many_requests

json = JSON.parse(response.body)
assert json.key?('error')
assert_includes json['error'], 'Rate limit exceeded'
end
end
end
4 changes: 4 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ class TestCase
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all

setup do
Rack::Attack.cache.store.clear if defined?(Rack::Attack)
end

# Add more helper methods to be used by all tests here...
end
end
Loading