Compare commits

...

10 Commits

24 changed files with 156 additions and 48 deletions

View File

@@ -20,3 +20,5 @@ group :development, :test do
end end
gem "faraday", "~> 2.14" gem "faraday", "~> 2.14"
gem "jbuilder", "~> 2.14"

View File

@@ -102,6 +102,9 @@ GEM
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.15.1) json (2.15.1)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.24.1)
@@ -252,6 +255,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
debug debug
faraday (~> 2.14) faraday (~> 2.14)
jbuilder (~> 2.14)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.0.3) rails (~> 8.0.3)
rspec-rails (~> 8.0) rspec-rails (~> 8.0)

View File

@@ -18,23 +18,20 @@
- You know this one; install dependencies - You know this one; install dependencies
2. Set `NPS_API_KEY` using your tool of choice 2. Set `NPS_API_KEY` using your tool of choice
3. `bin/rails db:prepare` 3. `bin/rails db:prepare`
- This will create and load your database - This will create and seed your database
- hint: `tail -f log/development.log` to see what's going on - hint: `tail -f log/development.log` to see what's going on
4. `bin/rails server` 4. `bin/rails server`
- Congrats you're ready to receive requests - Congrats you're ready to receive requests
``` ```
curl 'localhost:3000/api/v1/parks?per_page=1' | jq '.' curl 'localhost:3000/api/v1/stats'
[ {
{ "park_count": 474,
"id": 1, "alert_count": 708,
"code": "abli", "most_alerts": {
"name": "Abraham Lincoln Birthplace", "park_code": "zion",
"states": "KY", "alert_count": 6
"properties": {},
"created_at": "2025-10-08T09:26:00.202Z",
"updated_at": "2025-10-08T09:26:00.202Z"
} }
] }
``` ```
## Testing ## Testing
@@ -43,11 +40,10 @@
bin/rspec bin/rspec
``` ```
## TODO ## Notes
- What additional cols to add to parks model? - What additional cols to add to parks model?
- Order results on index actions - Order results on index actions
- Return total pages for index actions
- Enum on category for alerts - Enum on category for alerts
- index states for efficient search? - index states for efficient search?
- full text search in sqlite? - full text search in sqlite?

View File

@@ -3,11 +3,11 @@ class Api::V1::BaseController < ApplicationController
private private
def per_page def pagination_limit
(params[:per_page].presence || DEFAULT_PAGE_SIZE).to_i (params[:limit].presence || DEFAULT_PAGE_SIZE).to_i
end end
def page def pagination_offset
(params[:page].presence || 1).to_i (params[:offset].presence || 0).to_i
end end
end end

View File

@@ -3,7 +3,12 @@ module Api::V1::Parks
before_action :set_park before_action :set_park
def index def index
render json: @park.alerts.limit(per_page).offset((page - 1) * per_page) render json: {
total: @park.alerts.count,
limit: pagination_limit,
offset: pagination_offset,
alerts: @park.alerts.limit(pagination_limit).offset(pagination_offset)
}
end end
end end
end end

View File

@@ -5,9 +5,14 @@ module Api::V1
def index def index
parks = Park.all parks = Park.all
if params[:state].present? if params[:state].present?
parks = parks.where("states like '%' || ? || '%'", params[:state]) parks = parks.joins(:states).where(parks_by_states: { state: params[:state] })
end end
render json: parks.limit(per_page).offset((page - 1) * per_page) render json: {
total: parks.count,
limit: pagination_limit,
offset: pagination_offset,
parks: parks.limit(pagination_limit).offset(pagination_offset)
}
end end
def show def show

View File

@@ -0,0 +1,9 @@
module Api::V1
class StatsController < BaseController
def index
@park_count = Park.count
@alert_count = Alert.count
@park_with_most_alerts = Alert.group(:park_code).count.max
end
end
end

View File

@@ -7,8 +7,8 @@ class NpsClient
conn.get('parks', { start: offset }) conn.get('parks', { start: offset })
end end
def alerts(park_code:, offset: 0) def alerts(state:, offset: 0)
conn.get('alerts', { parkCode: park_code, start: offset }) conn.get('alerts', { stateCode: state, start: offset })
end end
private private

View File

@@ -1,4 +1,7 @@
class Park < ApplicationRecord class Park < ApplicationRecord
has_many :alerts, foreign_key: :park_code, primary_key: :code, has_many :alerts, foreign_key: :park_code, primary_key: :code,
inverse_of: :park, dependent: :destroy inverse_of: :park, dependent: :destroy
has_many :states, class_name: "ParksByState",
foreign_key: :park_code, primary_key: :code,
inverse_of: :parks, dependent: :destroy
end end

View File

@@ -0,0 +1,4 @@
class ParksByState < ApplicationRecord
has_many :parks, foreign_key: :park_code, primary_key: :code,
inverse_of: :states
end

View File

@@ -4,10 +4,12 @@ class Seed::Alerts
end end
def call def call
Park.find_each do |park| ParksByState.distinct.pluck(:state).each do |state|
relevant_park_codes = ParksByState.where(state: state).pluck(:park_code)
offset, total = 0, 1 offset, total = 0, 1
while offset < total do while offset < total do
response_body = NpsClient.current.alerts(park_code: park.code, offset: offset).body response_body = NpsClient.current.alerts(state: state, offset: offset).body
offset = response_body['start'].to_i + response_body['limit'].to_i offset = response_body['start'].to_i + response_body['limit'].to_i
total = response_body['total'].to_i total = response_body['total'].to_i
alerts = response_body['data'] alerts = response_body['data']
@@ -15,8 +17,9 @@ class Seed::Alerts
.map do |identifier, category, description, indexed_date, park_code, title, url| .map do |identifier, category, description, indexed_date, park_code, title, url|
{ identifier:, category:, description:, indexed_date:, park_code:, title:, url: } { identifier:, category:, description:, indexed_date:, park_code:, title:, url: }
end end
.select { relevant_park_codes.include?(it[:park_code]) }
Alert.upsert_all(alerts, unique_by: :identifier) Alert.upsert_all(alerts, unique_by: :identifier)
Rails.logger.info("Upserted #{alerts.count} alerts for #{park.name}") Rails.logger.info("Upserted #{alerts.count} alerts for #{state}")
end end
end end
end end

View File

@@ -9,13 +9,18 @@ class Seed::Parks
response_body = NpsClient.current.parks(offset: offset).body response_body = NpsClient.current.parks(offset: offset).body
offset = response_body['start'].to_i + response_body['limit'].to_i offset = response_body['start'].to_i + response_body['limit'].to_i
total = response_body['total'].to_i total = response_body['total'].to_i
parks = response_body['data'] parks, states = response_body['data']
.pluck('parkCode', 'name', 'states', 'activities', 'operatingHours') .pluck('parkCode', 'name', 'states', 'activities', 'operatingHours')
.map do |code, name, states, activities, operating_hours| .map do |code, name, states, activities, operating_hours|
{ code:, name:, states: , properties: { activities:, operating_hours: } } [
end { code:, name:, properties: { activities:, operating_hours: } },
states.split(',').map { { state: it.strip.upcase, park_code: code } }
]
end.transpose
Park.upsert_all(parks, unique_by: :code) Park.upsert_all(parks, unique_by: :code)
ParksByState.upsert_all(states.flatten, unique_by: :index_parks_by_states_on_state_and_park_code)
Rails.logger.info("Upserted #{parks.count} national parks") Rails.logger.info("Upserted #{parks.count} national parks")
Rails.logger.info("Upserted #{states.flatten.count} park by state records")
end end
end end
end end

View File

@@ -0,0 +1,6 @@
json.park_count @park_count
json.alert_count @alert_count
json.most_alerts do
json.park_code @park_with_most_alerts[0]
json.alert_count @park_with_most_alerts[1]
end

View File

@@ -1,9 +1,10 @@
Rails.application.routes.draw do Rails.application.routes.draw do
namespace :api do namespace :api do
namespace :v1 do namespace :v1, defaults: { format: :json } do
resources :parks, only: %i[index show], param: :code do resources :parks, only: %i[index show], param: :code do
resources :alerts, only: %i[index], module: :parks resources :alerts, only: %i[index], module: :parks
end end
resources :stats, only: %i[index]
end end
end end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

View File

@@ -0,0 +1,14 @@
class CreateParksByStates < ActiveRecord::Migration[8.0]
def change
create_table :parks_by_states do |t|
t.string :park_code, null: false
t.string :state, null: false
t.timestamps
end
add_index :parks_by_states, [:state, :park_code], unique: true
add_index :parks_by_states, :park_code
add_foreign_key :parks_by_states, :parks, column: :park_code, primary_key: :code
end
end

View File

@@ -0,0 +1,5 @@
class DropParkStates < ActiveRecord::Migration[8.0]
def change
remove_column :parks, :states, :text
end
end

13
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_10_08_063847) do ActiveRecord::Schema[8.0].define(version: 2025_10_08_151039) do
create_table "alerts", force: :cascade do |t| create_table "alerts", force: :cascade do |t|
t.string "category", null: false t.string "category", null: false
t.text "description" t.text "description"
@@ -28,12 +28,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_08_063847) do
create_table "parks", force: :cascade do |t| create_table "parks", force: :cascade do |t|
t.string "code", null: false t.string "code", null: false
t.string "name", null: false t.string "name", null: false
t.text "states", null: false
t.json "properties", default: {}, null: false t.json "properties", default: {}, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["code"], name: "index_parks_on_code", unique: true t.index ["code"], name: "index_parks_on_code", unique: true
end end
create_table "parks_by_states", force: :cascade do |t|
t.string "park_code", null: false
t.string "state", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["park_code"], name: "index_parks_by_states_on_park_code"
t.index ["state", "park_code"], name: "index_parks_by_states_on_state_and_park_code", unique: true
end
add_foreign_key "alerts", "parks", column: "park_code", primary_key: "code" add_foreign_key "alerts", "parks", column: "park_code", primary_key: "code"
add_foreign_key "parks_by_states", "parks", column: "park_code", primary_key: "code"
end end

View File

@@ -1,6 +1,7 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one: one:
identifier: 8e8031b0-0a49-4c89-9422-0a76c7785b6b
park_code: crla park_code: crla
title: Fire Restrictions in Effect title: Fire Restrictions in Effect
description: To ensure public safety and to provide the highest degree of protection to park resources, fire restrictions are in effect until further notice. description: To ensure public safety and to provide the highest degree of protection to park resources, fire restrictions are in effect until further notice.
@@ -8,6 +9,7 @@ one:
indexed_date: 2025-07-04 22:07:59.0 indexed_date: 2025-07-04 22:07:59.0
two: two:
identifier: d2bf3535-43db-4cfa-8615-a79801449cd1
park_code: olym park_code: olym
title: Roads May Be Icy - Please Use Caution! title: Roads May Be Icy - Please Use Caution!
description: Even though the sun is shining, the open roads in the park may still have icy patches, especially in the mornings and evenings when the temperatures drop. Please drive with caution. description: Even though the sun is shining, the open roads in the park may still have icy patches, especially in the mornings and evenings when the temperatures drop. Please drive with caution.

View File

@@ -3,11 +3,9 @@
one: one:
code: crla code: crla
name: Crater Lake National Park name: Crater Lake National Park
states: OR
properties: {} properties: {}
two: two:
code: olym code: olym
name: Olympic National Park name: Olympic National Park
states: WA
properties: {} properties: {}

9
spec/fixtures/parks_by_states.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
park_code: crla
state: OR
two:
park_code: olym
state: WA

View File

@@ -0,0 +1,4 @@
require 'rails_helper'
RSpec.describe ParksByState, type: :model do
end

View File

@@ -5,8 +5,8 @@ RSpec.describe "Api::V1::Parks::Alerts", type: :request do
it "returns the alerts associated to a park" do it "returns the alerts associated to a park" do
get api_v1_park_alerts_url(parks(:one).code) get api_v1_park_alerts_url(parks(:one).code)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.parsed_body.count).to eq(1) expect(response.parsed_body["alerts"].count).to eq(1)
expect(response.parsed_body).to include( expect(response.parsed_body["alerts"]).to include(
hash_including( hash_including(
:description, park_code: "crla", title: "Fire Restrictions in Effect", :description, park_code: "crla", title: "Fire Restrictions in Effect",
category: "caution", indexed_date: "2025-07-04T22:07:59.000Z" category: "caution", indexed_date: "2025-07-04T22:07:59.000Z"

View File

@@ -5,32 +5,35 @@ RSpec.describe "Api::V1::Parks", type: :request do
it "returns parks" do it "returns parks" do
get api_v1_parks_url get api_v1_parks_url
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.parsed_body).to include( expect(response.parsed_body["parks"]).to include(
hash_including( hash_including(
code: "crla", name: "Crater Lake National Park", states: "OR" code: "crla", name: "Crater Lake National Park"
) )
) )
expect(response.parsed_body).to include( expect(response.parsed_body["parks"]).to include(
hash_including( hash_including(
code: "olym", name: "Olympic National Park", states: "WA" code: "olym", name: "Olympic National Park"
) )
) )
end end
it "filters by state" do it "filters by state" do
get api_v1_parks_url, params: { state: "OR" } get api_v1_parks_url, params: { state: "OR" }
expect(response.parsed_body.pluck("code")).to eq(["crla"]) expect(response.parsed_body["parks"].pluck("code")).to eq(["crla"])
end end
context "with pagination" do context "with pagination" do
it "respects page size param" do it "respects limit param" do
get api_v1_parks_url, params: { per_page: 1 } get api_v1_parks_url, params: { limit: 1 }
expect(response.parsed_body.size).to eq(1) expect(response.parsed_body["total"]).to eq(2)
expect(response.parsed_body["limit"]).to eq(1)
expect(response.parsed_body["offset"]).to eq(0)
expect(response.parsed_body["parks"].size).to eq(1)
end end
it "respects page param" do it "respects offset param" do
get api_v1_parks_url, params: { per_page: 1, page: 2 } get api_v1_parks_url, params: { offset: 1 }
expect(response.parsed_body.first["code"]).to eq("crla") expect(response.parsed_body["parks"].first["code"]).to eq("crla")
end end
end end
end end
@@ -46,7 +49,7 @@ RSpec.describe "Api::V1::Parks", type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.parsed_body).to match( expect(response.parsed_body).to match(
hash_including( hash_including(
code: "crla", name: "Crater Lake National Park", states: "OR" code: "crla", name: "Crater Lake National Park"
) )
) )
end end

View File

@@ -0,0 +1,21 @@
require 'rails_helper'
RSpec.describe "Api::V1::Stats", type: :request do
describe "GET /stats" do
it "returns summary stats" do
alerts(:two).update!(park_code: parks(:one).code)
get api_v1_stats_url
expect(response).to have_http_status(:success)
expect(response.parsed_body).to match(
park_count: 2,
alert_count: 2,
most_alerts: {
park_code: "crla",
alert_count: 2
}
)
end
end
end