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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/) | ~78 files |
| `app/controllers/` | Rails controllers (admin/, events/, home/, api/) | ~80 files |
| `app/views/` | ERB templates | ~745 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
Expand Down
16 changes: 16 additions & 0 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module Api
# Base for the public, read-only JSON API. These endpoints serve only
# unconditionally public data, so authentication is skipped and errors are
# rendered as JSON rather than the HTML redirects ApplicationController uses.
class BaseController < ApplicationController
skip_before_action :authenticate_user!

rescue_from ActiveRecord::RecordNotFound do
render json: { error: "Not found" }, status: :not_found
end

rescue_from ActionPolicy::Unauthorized do
render json: { error: "Forbidden" }, status: :forbidden
end
end
end
38 changes: 38 additions & 0 deletions app/controllers/api/v1/stories_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module Api
module V1
class StoriesController < Api::BaseController
# Cap page size so a caller can't request an unbounded payload.
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100

# GET /api/v1/stories
# Publicly featured stories only (published + publicly_visible +
# publicly_featured).
def index
authorize! Story, to: :index?
# `authorized_scope` layers StoryPolicy (anonymous callers collapse to
# `publicly_visible`) over the `publicly_featured` scope, which already
# includes the public floor.
@stories = authorized_scope(Story.publicly_featured)
.includes(:windows_type, :organization, :author, :primary_asset, :sectors,
{ categories: :category_type }, created_by: :person)
.order(created_at: :desc)
.paginate(page: params[:page], per_page: per_page)
end

# GET /api/v1/stories/:id
def show
@story = Story.publicly_featured.find(params[:id])
authorize! @story
end

private

def per_page
requested = params[:per_page].to_i
return DEFAULT_PER_PAGE unless requested.positive?
[ requested, MAX_PER_PAGE ].min
end
end
end
end
36 changes: 36 additions & 0 deletions app/views/api/v1/stories/_story.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
json.title story.title

# Credited author, honoring the story's privacy preference (may be "Anonymous").
json.author story.author_credit

json.organization do
json.name story.organization_name
json.locality story.organization_locality
end

json.url story_url(story)

# Tags applied to the story: the windows type, categories (grouped by category
# type, e.g. "Age range", "Story category"), and sectors.
json.tags do
json.windows_type story.windows_type&.name
# Always emit `categories` as an object (`{}` when untagged) so consumers can
# iterate it unconditionally.
json.categories story.categories
.group_by { |c| c.category_type&.display_label || "Other" }
.transform_values { |cats| cats.map(&:name).sort }
json.sectors story.sector_names_all
end

json.body story.rhino_body.to_plain_text

if story.primary_asset&.file&.attached?
json.image_url rails_blob_url(story.primary_asset.file)
json.thumbnail_url rails_representation_url(story.primary_asset.file.variant(:thumbnail))
else
json.image_url nil
json.thumbnail_url nil
end

json.created_at story.created_at.iso8601
json.updated_at story.updated_at.iso8601
8 changes: 8 additions & 0 deletions app/views/api/v1/stories/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
json.stories @stories, partial: "api/v1/stories/story", as: :story

json.meta do
json.current_page @stories.current_page
json.per_page @stories.per_page
json.total_entries @stories.total_entries
json.total_pages @stories.total_pages
end
3 changes: 3 additions & 0 deletions app/views/api/v1/stories/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
json.story do
json.partial! "api/v1/stories/story", story: @story
end
7 changes: 7 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -317,5 +317,12 @@
resources :video_recordings, only: :index
end

# Public, read-only JSON API for publicly visible stories. No authentication.
namespace :api, defaults: { format: "json" } do
namespace :v1 do
resources :stories, only: [ :index, :show ]
end
end

root to: "home#index"
end
102 changes: 102 additions & 0 deletions spec/requests/api/v1/stories_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
require "rails_helper"

RSpec.describe "Api::V1::Stories", type: :request do
let!(:public_featured_story) do
create(:story, :published, :publicly_visible, :publicly_featured,
title: "Public featured story")
end

# publicly visible but not publicly featured — excluded from this API
let!(:public_story) do
create(:story, :published, :publicly_visible, title: "Public story")
end

# published but not publicly_visible
let!(:internal_story) do
create(:story, :published, title: "Internal story")
end

# not published at all
let!(:draft_story) do
create(:story, :unpublished, :publicly_visible, :publicly_featured, title: "Draft story")
end

def json
JSON.parse(response.body)
end

describe "GET /api/v1/stories" do
it "returns only publicly featured stories, without authentication" do
get "/api/v1/stories"

expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/json")

titles = json["stories"].map { |s| s["title"] }
expect(titles).to contain_exactly("Public featured story")
expect(titles).not_to include("Public story", "Internal story", "Draft story")
end

it "includes pagination metadata" do
get "/api/v1/stories"

expect(json["meta"]).to include(
"current_page" => 1,
"per_page" => Api::V1::StoriesController::DEFAULT_PER_PAGE,
"total_entries" => 1
)
end

it "caps per_page at the maximum" do
get "/api/v1/stories", params: { per_page: "9999" }

expect(json["meta"]["per_page"]).to eq(Api::V1::StoriesController::MAX_PER_PAGE)
end
end

describe "GET /api/v1/stories/:id" do
it "returns a publicly featured story" do
get "/api/v1/stories/#{public_featured_story.id}"

expect(response).to have_http_status(:ok)
expect(json["story"]).to include(
"title" => "Public featured story",
"url" => story_url(public_featured_story)
)
end

it "groups the story's tags into categories (by type) and sectors" do
story = create(:story, :published, :publicly_visible, :publicly_featured, title: "Tagged story")
age_type = create(:category_type, name: "AgeRange")
category = create(:category, name: "6-12", category_type: age_type)
sector = create(:sector, name: "Domestic violence")
create(:categorizable_item, category: category, categorizable: story)
create(:sectorable_item, sector: sector, sectorable: story)

get "/api/v1/stories/#{story.id}"

tags = json["story"]["tags"]
expect(tags["categories"]).to eq("Age range" => [ "6-12" ])
expect(tags["sectors"]).to eq([ "Domestic violence" ])
end

it "404s for a publicly visible story that is not publicly featured" do
get "/api/v1/stories/#{public_story.id}"

expect(response).to have_http_status(:not_found)
expect(json["error"]).to eq("Not found")
end

it "404s for a story that is not publicly visible" do
get "/api/v1/stories/#{internal_story.id}"

expect(response).to have_http_status(:not_found)
end

it "404s for an unknown id" do
get "/api/v1/stories/0"

expect(response).to have_http_status(:not_found)
end
end
end
17 changes: 17 additions & 0 deletions spec/routing/api/v1/stories_routing_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "rails_helper"

RSpec.describe Api::V1::StoriesController, type: :routing do
describe "routing" do
it "routes to #index" do
expect(get: "/api/v1/stories").to route_to("api/v1/stories#index", format: "json")
end

it "routes to #show" do
expect(get: "/api/v1/stories/1").to route_to("api/v1/stories#show", id: "1", format: "json")
end

it "does not route to #create" do
expect(post: "/api/v1/stories").not_to be_routable
end
end
end