Compare commits

...

10 Commits

24 changed files with 156 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,12 @@ module Api::V1::Parks
before_action :set_park
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

View File

@@ -5,9 +5,14 @@ module Api::V1
def index
parks = Park.all
if params[:state].present?
parks = parks.where("states like '%' || ? || '%'", params[:state])
parks = parks.joins(:states).where(parks_by_states: { state: params[:state] })
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
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 })
end
def alerts(park_code:, offset: 0)
conn.get('alerts', { parkCode: park_code, start: offset })
def alerts(state:, offset: 0)
conn.get('alerts', { stateCode: state, start: offset })
end
private

View File

@@ -1,4 +1,7 @@
class Park < ApplicationRecord
has_many :alerts, foreign_key: :park_code, primary_key: :code,
inverse_of: :park, dependent: :destroy
has_many :states, class_name: "ParksByState",
foreign_key: :park_code, primary_key: :code,
inverse_of: :parks, dependent: :destroy
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
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
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
total = response_body['total'].to_i
alerts = response_body['data']
@@ -15,8 +17,9 @@ class Seed::Alerts
.map do |identifier, category, description, indexed_date, park_code, title, url|
{ identifier:, category:, description:, indexed_date:, park_code:, title:, url: }
end
.select { relevant_park_codes.include?(it[:park_code]) }
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

View File

@@ -9,13 +9,18 @@ class Seed::Parks
response_body = NpsClient.current.parks(offset: offset).body
offset = response_body['start'].to_i + response_body['limit'].to_i
total = response_body['total'].to_i
parks = response_body['data']
parks, states = response_body['data']
.pluck('parkCode', 'name', 'states', 'activities', 'operatingHours')
.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)
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 #{states.flatten.count} park by state records")
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
namespace :api do
namespace :v1 do
namespace :v1, defaults: { format: :json } do
resources :parks, only: %i[index show], param: :code do
resources :alerts, only: %i[index], module: :parks
end
resources :stats, only: %i[index]
end
end
# 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.
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|
t.string "category", null: false
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|
t.string "code", null: false
t.string "name", null: false
t.text "states", null: false
t.json "properties", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_parks_on_code", unique: true
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 "parks_by_states", "parks", column: "park_code", primary_key: "code"
end

View File

@@ -1,6 +1,7 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
identifier: 8e8031b0-0a49-4c89-9422-0a76c7785b6b
park_code: crla
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.
@@ -8,6 +9,7 @@ one:
indexed_date: 2025-07-04 22:07:59.0
two:
identifier: d2bf3535-43db-4cfa-8615-a79801449cd1
park_code: olym
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.

View File

@@ -3,11 +3,9 @@
one:
code: crla
name: Crater Lake National Park
states: OR
properties: {}
two:
code: olym
name: Olympic National Park
states: WA
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
get api_v1_park_alerts_url(parks(:one).code)
expect(response).to have_http_status(:success)
expect(response.parsed_body.count).to eq(1)
expect(response.parsed_body).to include(
expect(response.parsed_body["alerts"].count).to eq(1)
expect(response.parsed_body["alerts"]).to include(
hash_including(
:description, park_code: "crla", title: "Fire Restrictions in Effect",
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
get api_v1_parks_url
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
expect(response.parsed_body["parks"]).to include(
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(
code: "olym", name: "Olympic National Park", states: "WA"
code: "olym", name: "Olympic National Park"
)
)
end
it "filters by state" do
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
context "with pagination" do
it "respects page size param" do
get api_v1_parks_url, params: { per_page: 1 }
expect(response.parsed_body.size).to eq(1)
it "respects limit param" do
get api_v1_parks_url, params: { limit: 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
it "respects page param" do
get api_v1_parks_url, params: { per_page: 1, page: 2 }
expect(response.parsed_body.first["code"]).to eq("crla")
it "respects offset param" do
get api_v1_parks_url, params: { offset: 1 }
expect(response.parsed_body["parks"].first["code"]).to eq("crla")
end
end
end
@@ -46,7 +49,7 @@ RSpec.describe "Api::V1::Parks", type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body).to match(
hash_including(
code: "crla", name: "Crater Lake National Park", states: "OR"
code: "crla", name: "Crater Lake National Park"
)
)
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