diff --git a/AGENTS.md b/AGENTS.md index 7117b9b6d..ad30cc711 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 000000000..25d34b052 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/stories_controller.rb b/app/controllers/api/v1/stories_controller.rb new file mode 100644 index 000000000..61aa234ae --- /dev/null +++ b/app/controllers/api/v1/stories_controller.rb @@ -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 diff --git a/app/views/api/v1/stories/_story.json.jbuilder b/app/views/api/v1/stories/_story.json.jbuilder new file mode 100644 index 000000000..e0c7dcd44 --- /dev/null +++ b/app/views/api/v1/stories/_story.json.jbuilder @@ -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 diff --git a/app/views/api/v1/stories/index.json.jbuilder b/app/views/api/v1/stories/index.json.jbuilder new file mode 100644 index 000000000..a2f7189e4 --- /dev/null +++ b/app/views/api/v1/stories/index.json.jbuilder @@ -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 diff --git a/app/views/api/v1/stories/show.json.jbuilder b/app/views/api/v1/stories/show.json.jbuilder new file mode 100644 index 000000000..85d426cbc --- /dev/null +++ b/app/views/api/v1/stories/show.json.jbuilder @@ -0,0 +1,3 @@ +json.story do + json.partial! "api/v1/stories/story", story: @story +end diff --git a/config/routes.rb b/config/routes.rb index 3764344ea..6948f3faa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/api/v1/stories_spec.rb b/spec/requests/api/v1/stories_spec.rb new file mode 100644 index 000000000..aabb4a1ad --- /dev/null +++ b/spec/requests/api/v1/stories_spec.rb @@ -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 diff --git a/spec/routing/api/v1/stories_routing_spec.rb b/spec/routing/api/v1/stories_routing_spec.rb new file mode 100644 index 000000000..c46c0984e --- /dev/null +++ b/spec/routing/api/v1/stories_routing_spec.rb @@ -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