Compare commits
10 Commits
7a0c2b8f90
...
f5009780aa
| Author | SHA1 | Date | |
|---|---|---|---|
| f5009780aa | |||
| b3d96e07b1 | |||
| fcbc513676 | |||
| b4f45bf5c8 | |||
| 55f712d043 | |||
| 7bf4073bd2 | |||
| 513f1334fb | |||
| 4806a59d96 | |||
| 3db7db9624 | |||
| 1285f95ad3 |
2
Gemfile
2
Gemfile
@@ -20,3 +20,5 @@ group :development, :test do
|
||||
end
|
||||
|
||||
gem "faraday", "~> 2.14"
|
||||
|
||||
gem "jbuilder", "~> 2.14"
|
||||
|
||||
@@ -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)
|
||||
|
||||
24
README.md
24
README.md
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
app/controllers/api/v1/stats_controller.rb
Normal file
9
app/controllers/api/v1/stats_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
app/models/parks_by_state.rb
Normal file
4
app/models/parks_by_state.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ParksByState < ApplicationRecord
|
||||
has_many :parks, foreign_key: :park_code, primary_key: :code,
|
||||
inverse_of: :states
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
app/views/api/v1/stats/index.json.jbuilder
Normal file
6
app/views/api/v1/stats/index.json.jbuilder
Normal 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
|
||||
@@ -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
|
||||
|
||||
14
db/migrate/20251008150542_create_parks_by_states.rb
Normal file
14
db/migrate/20251008150542_create_parks_by_states.rb
Normal 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
|
||||
5
db/migrate/20251008151039_drop_park_states.rb
Normal file
5
db/migrate/20251008151039_drop_park_states.rb
Normal 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
13
db/schema.rb
generated
@@ -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
|
||||
|
||||
2
spec/fixtures/alerts.yml
vendored
2
spec/fixtures/alerts.yml
vendored
@@ -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.
|
||||
|
||||
2
spec/fixtures/parks.yml
vendored
2
spec/fixtures/parks.yml
vendored
@@ -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
9
spec/fixtures/parks_by_states.yml
vendored
Normal 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
|
||||
4
spec/models/parks_by_state_spec.rb
Normal file
4
spec/models/parks_by_state_spec.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ParksByState, type: :model do
|
||||
end
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
21
spec/requests/api/v1/stats_spec.rb
Normal file
21
spec/requests/api/v1/stats_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user