diff --git a/app/controllers/api/v1/audience_segments_controller.rb b/app/controllers/api/v1/audience_segments_controller.rb index 3b5b8c3f8..d2eddf5a7 100644 --- a/app/controllers/api/v1/audience_segments_controller.rb +++ b/app/controllers/api/v1/audience_segments_controller.rb @@ -1,12 +1,30 @@ module Api module V1 class AudienceSegmentsController < ApiController + DEFAULT_PER_PAGE = 30 MAX_USER_IDS = 10_000 + USER_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id username name twitter_username github_username + profile_image website_url location summary created_at + ].freeze before_action :authenticate_with_api_key! before_action :require_admin before_action :restrict_user_ids, only: %i[add_users remove_users] + def index + page, per_page = pagination_params + @segments = scope.including_user_counts.order(id: :desc).page(page).per(per_page) + + render json: @segments + end + + def show + @segment = scope.including_user_counts.find(params[:id]) + + render json: @segment + end + def create @segment = AudienceSegment.create!(type_of: "manual") @@ -25,6 +43,13 @@ module Api end end + def users + @segment = scope.find(params[:id]) + + page, per_page = pagination_params + @users = @segment.users.joins(:profile).select(USER_ATTRIBUTES_FOR_SERIALIZATION).page(page).per(per_page) + end + def add_users @segment = scope.find(params[:id]) @@ -50,6 +75,14 @@ module Api render json: { error: "Too many user IDs provided" }, status: :unprocessable_entity end + def pagination_params + per_page_param = params[:per_page] || DEFAULT_PER_PAGE + max_per_page = ApplicationConfig["API_PER_PAGE_MAX"] || 1000 + per_page = [per_page_param, max_per_page].map(&:to_i).min + + [params[:page].to_i, per_page] + end + def scope AudienceSegment.manual end diff --git a/app/models/audience_segment.rb b/app/models/audience_segment.rb index e5bbe34ae..ae8be7ccf 100644 --- a/app/models/audience_segment.rb +++ b/app/models/audience_segment.rb @@ -58,6 +58,12 @@ class AudienceSegment < ApplicationRecord .map { |(id, type)| [I18n.t("models.#{model_name.i18n_key}.type_ofs.#{type}"), id] } end + def self.including_user_counts + left_joins(:segmented_users) + .select("audience_segments.*, COUNT(segmented_users.id) AS user_count") + .group("audience_segments.id") + end + def persist_recently_active_users self.users = self.class.recently_active_users_in_segment(type_of) end diff --git a/app/views/api/v1/audience_segments/users.json.jbuilder b/app/views/api/v1/audience_segments/users.json.jbuilder new file mode 100644 index 000000000..c901adcb9 --- /dev/null +++ b/app/views/api/v1/audience_segments/users.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @users do |user| + json.partial! "api/v1/shared/user_show", user: user +end diff --git a/config/routes.rb b/config/routes.rb index 7206fe2eb..5f73cdffd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,8 @@ Rails.application.routes.draw do put "unpublish", on: :member end - resources :segments, controller: "audience_segments", only: %i[create destroy] do + resources :segments, controller: "audience_segments", only: %i[index show create destroy] do + get "users", on: :member put "add_users", on: :member put "remove_users", on: :member end diff --git a/spec/requests/api/v1/audience_segments_spec.rb b/spec/requests/api/v1/audience_segments_spec.rb index 25a121be8..1972121a9 100644 --- a/spec/requests/api/v1/audience_segments_spec.rb +++ b/spec/requests/api/v1/audience_segments_spec.rb @@ -46,6 +46,87 @@ RSpec.describe "Api::V1::AudienceSegments" do end end + describe "GET /api/segments" do + subject(:make_request) { get api_segments_path, headers: headers } + + let(:first_manual_segment) { AudienceSegment.create!(type_of: "manual") } + let(:second_manual_segment) { AudienceSegment.create!(type_of: "manual") } + let(:automatic_segment) { AudienceSegment.create!(type_of: "trusted") } + let(:users) { create_list(:user, 3) } + + it_behaves_like "an admin-only protected resource" + + it "returns only manual segments including their user counts" do + first_manual_segment.users << users + expected_data = [ + hash_including( + "id" => first_manual_segment.id, + "type_of" => "manual", + "user_count" => 3, + ), + hash_including( + "id" => second_manual_segment.id, + "type_of" => "manual", + "user_count" => 0, + ), + ] + excluded_data = hash_including("id" => automatic_segment.id) + + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + + expect(response.parsed_body).to include(*expected_data) + expect(response.parsed_body).not_to include(excluded_data) + end + + it "supports pagination" do + 5.times.each { AudienceSegment.create!(type_of: :manual) } + + get api_segments_path, params: { page: 1, per_page: 3 }, headers: headers + expect(response.parsed_body.length).to eq(3) + get api_segments_path, params: { page: 2, per_page: 4 }, headers: headers + expect(response.parsed_body.length).to eq(1) + end + + it "respects API_PER_PAGE_MAX env variable limit" do + 5.times.each { AudienceSegment.create!(type_of: :manual) } + + allow(ApplicationConfig).to receive(:[]).and_return(nil) + allow(ApplicationConfig).to receive(:[]).with("APP_PROTOCOL").and_return("http://") + allow(ApplicationConfig).to receive(:[]).with("API_PER_PAGE_MAX").and_return(3) + + get api_segments_path, params: { per_page: 10 }, headers: headers + expect(response.parsed_body.length).to eq(3) + end + end + + describe "GET /api/segments/:id" do + subject(:make_request) { get api_segment_path(segment.id), headers: headers } + + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 3) } + + it_behaves_like "an admin-only protected resource" + + it_behaves_like "an endpoint for only manual audience segments" + + it "returns the segment including its user count" do + segment.users << users + + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + expect(response.parsed_body).to include( + "id" => segment.id, + "type_of" => "manual", + "user_count" => 3, + ) + end + end + describe "POST /api/segments" do subject(:make_request) { post api_segments_path, headers: headers } @@ -66,6 +147,60 @@ RSpec.describe "Api::V1::AudienceSegments" do end end + describe "GET /api/segments/:id/users" do + subject(:make_request) { get users_api_segment_path(segment.id), headers: headers } + + let(:segment) { AudienceSegment.create!(type_of: "manual") } + let(:users) { create_list(:user, 5) } + + it_behaves_like "an admin-only protected resource" + + it_behaves_like "an endpoint for only manual audience segments" + + it "returns a list of users in the segment" do + segment.users << users + + expected_data = users.map do |user| + hash_including( + "id" => user.id, + "username" => user.username, + "name" => user.name, + "twitter_username" => user.twitter_username, + "github_username" => user.github_username, + "summary" => user.profile.summary, + "location" => user.profile.location, + "website_url" => user.profile.website_url, + ) + end + + make_request + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + expect(response.parsed_body).to match_array(expected_data) + end + + it "supports pagination" do + segment.users << users + + get users_api_segment_path(segment.id), params: { page: 1, per_page: 3 }, headers: headers + expect(response.parsed_body.length).to eq(3) + get users_api_segment_path(segment.id), params: { page: 2, per_page: 4 }, headers: headers + expect(response.parsed_body.length).to eq(1) + end + + it "respects API_PER_PAGE_MAX env variable limit" do + segment.users << users + + allow(ApplicationConfig).to receive(:[]).and_return(nil) + allow(ApplicationConfig).to receive(:[]).with("APP_PROTOCOL").and_return("http://") + allow(ApplicationConfig).to receive(:[]).with("API_PER_PAGE_MAX").and_return(3) + + get users_api_segment_path(segment.id), params: { per_page: 5 }, headers: headers + expect(response.parsed_body.length).to eq(3) + end + end + describe "PUT /api/segments/:id/add_users" do subject(:make_request) do put add_users_api_segment_path(segment.id), params: { user_ids: user_ids }, headers: headers, as: :json diff --git a/spec/requests/api/v1/docs/audience_segments_spec.rb b/spec/requests/api/v1/docs/audience_segments_spec.rb index 00fbc75f7..e34ec345a 100644 --- a/spec/requests/api/v1/docs/audience_segments_spec.rb +++ b/spec/requests/api/v1/docs/audience_segments_spec.rb @@ -25,11 +25,59 @@ RSpec.describe "Api::V1::Docs::AudienceSegments" do admin_api_secret.user.add_role(:admin) end - describe "POST /segments" do - path "/api/segments" do + path "/api/segments" do + describe "GET /segments" do + get "Manually managed audience segments" do + tags "segments" + description "This endpoint allows the client to retrieve a list of audience segments. + +An audience segment is a group of users that can be targeted by a Billboard. This API only permits managing segments you create and maintain yourself. + +The endpoint supports pagination, and each page will contain `30` segments by default." + operationId "getSegments" + produces "application/json" + consumes "application/json" + + parameter "$ref": "#/components/parameters/perPageParam30to1000" + + response "200", "A List of manually managed audience segments" do + let(:"api-key") { admin_api_secret.secret } + let(:second_segment) { AudienceSegment.create!(type_of: "manual") } + schema type: :array, + items: { "$ref": "#/components/schemas/Segment" } + + before do + segment.users << users + second_segment.users << create(:user) + end + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + + add_examples + + run_test! + end + end + end + + describe "POST /segments" do post "Create a manually managed audience segment" do tags "segments" - description "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a DisplayAd/Billboard/Widget. This API only permits managing segments you create and maintain yourself." + description "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a Billboard. This API only permits managing segments you create and maintain yourself." operationId "createSegment" produces "application/json" consumes "application/json" @@ -61,11 +109,65 @@ RSpec.describe "Api::V1::Docs::AudienceSegments" do end end - describe "DELETE /segments/:id" do - path "/api/segments/{id}" do + path "/api/segments/{id}" do + describe "GET /segments/:id" do + get "A manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to retrieve a single manually-managed audience segment specified by ID." + operationId "getSegment" + produces "application/json" + consumes "application/json" + + parameter name: :id, in: :path, required: true, schema: id_schema + + response "200", "The audience segment" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + schema type: :object, + items: { "$ref": "#/components/schemas/Segment" } + + before do + segment.users << users + end + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "404", "Audience Segment Not Found" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { automatic_segment.id } + + add_examples + + run_test! + end + end + end + + describe "DELETE /segments/:id" do delete "Delete a manually managed audience segment" do tags "segments" - description "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any DisplayAds/Billboards/Widgets using them." + description "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any Billboards using them." operationId "deleteSegment" produces "application/json" consumes "application/json" @@ -125,6 +227,63 @@ RSpec.describe "Api::V1::Docs::AudienceSegments" do end end + describe "GET /segments/:id/users" do + path "/api/segments/{id}/users" do + get "Users in a manually managed audience segment" do + tags "segments" + description "This endpoint allows the client to retrieve a list of the users in an audience segment specified by ID. The endpoint supports pagination, and each page will contain `30` users by default." + operationId "getUsersInSegment" + produces "application/json" + consumes "application/json" + + parameter name: :id, in: :path, required: true, schema: id_schema + parameter "$ref": "#/components/parameters/perPageParam30to1000" + + response "200", "A List of users in the audience segment" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { segment.id } + schema type: :array, + items: { "$ref": "#/components/schemas/User" } + + before do + segment.users << users + end + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { nil } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:"api-key") { regular_api_secret.secret } + let(:id) { segment.id } + + add_examples + + run_test! + end + + response "404", "Audience Segment Not Found" do + let(:"api-key") { admin_api_secret.secret } + let(:id) { automatic_segment.id } + + add_examples + + run_test! + end + end + end + end + describe "PUT /segments/:id/add_users" do path "/api/segments/{id}/add_users" do put "Add users to a manually managed audience segment" do diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index dce343612..53bb22bcd 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -929,10 +929,63 @@ } }, "/api/segments": { + "get": { + "summary": "Manually managed audience segments", + "tags": ["segments"], + "description": "This endpoint allows the client to retrieve a list of audience segments.\n\nAn audience segment is a group of users that can be targeted by a Billboard. This API only permits managing segments you create and maintain yourself.\n\nThe endpoint supports pagination, and each page will contain `30` segments by default.", + "operationId": "getSegments", + "parameters": [ + { + "$ref": "#/components/parameters/perPageParam30to1000" + } + ], + "responses": { + "200": { + "description": "A List of manually managed audience segments", + "content": { + "application/json": { + "example": [ + { + "id": 127, + "created_at": "2023-05-19T13:29:19.013-05:00", + "type_of": "manual", + "updated_at": "2023-05-19T13:29:19.013-05:00", + "user_count": 1 + }, + { + "id": 126, + "created_at": "2023-05-19T13:29:19.013-05:00", + "type_of": "manual", + "updated_at": "2023-05-19T13:29:19.013-05:00", + "user_count": 3 + } + ], + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Segment" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + } + } + }, "post": { "summary": "Create a manually managed audience segment", "tags": ["segments"], - "description": "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a DisplayAd/Billboard/Widget. This API only permits managing segments you create and maintain yourself.", + "description": "This endpoint allows the client to create a new audience segment.\n\nAn audience segment is a group of users that can be targeted by a Billboard. This API only permits managing segments you create and maintain yourself.", "operationId": "createSegment", "responses": { "201": { @@ -963,10 +1016,72 @@ } }, "/api/segments/{id}": { + "get": { + "summary": "A manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to retrieve a single manually-managed audience segment specified by ID.", + "operationId": "getSegment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "The audience segment", + "content": { + "application/json": { + "example": { + "id": 131, + "created_at": "2023-05-19T13:29:19.481-05:00", + "type_of": "manual", + "updated_at": "2023-05-19T13:29:19.481-05:00", + "user_count": 3 + }, + "schema": { + "type": "object", + "items": { + "$ref": "#/components/schemas/Segment" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Audience Segment Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + } + } + }, "delete": { "summary": "Delete a manually managed audience segment", "tags": ["segments"], - "description": "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any DisplayAds/Billboards/Widgets using them.", + "description": "This endpoint allows the client to delete an audience segment specified by ID.\n\nAudience segments cannot be deleted if there are still any Billboards using them.", "operationId": "deleteSegment", "parameters": [ { @@ -1029,6 +1144,107 @@ } } }, + "/api/segments/{id}/users": { + "get": { + "summary": "Users in a manually managed audience segment", + "tags": ["segments"], + "description": "This endpoint allows the client to retrieve a list of the users in an audience segment specified by ID. The endpoint supports pagination, and each page will contain `30` users by default.", + "operationId": "getUsersInSegment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/perPageParam30to1000" + } + ], + "responses": { + "200": { + "description": "A List of users in the audience segment", + "content": { + "application/json": { + "example": [ + { + "type_of": "user", + "id": 1032, + "username": "username586", + "name": "Katrina \"Kirsten\" \\:/ Schiller", + "twitter_username": "twitter586", + "github_username": "github586", + "summary": null, + "location": null, + "website_url": null, + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1947/02641cc1-4268-4203-8f2d-59cf53d4433a.jpeg" + }, + { + "type_of": "user", + "id": 1033, + "username": "username587", + "name": "Breanne \"Steve\" \\:/ Marquardt", + "twitter_username": "twitter587", + "github_username": "github587", + "summary": null, + "location": null, + "website_url": null, + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1948/621613c2-2990-4b92-8a26-8dac49725a95.jpeg" + }, + { + "type_of": "user", + "id": 1034, + "username": "username588", + "name": "Eugene \"Jamar\" \\:/ Keebler", + "twitter_username": "twitter588", + "github_username": "github588", + "summary": null, + "location": null, + "website_url": null, + "joined_at": "May 19, 2023", + "profile_image": "/uploads/user/profile_image/1949/7d5a54bc-faf2-408b-a988-3ea5b8ef40f1.jpeg" + } + ], + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Audience Segment Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + } + } + } + }, "/api/segments/{id}/add_users": { "put": { "summary": "Add users to a manually managed audience segment",