diff --git a/app/controllers/api/v1/reactions_controller.rb b/app/controllers/api/v1/reactions_controller.rb new file mode 100644 index 000000000..a6512c299 --- /dev/null +++ b/app/controllers/api/v1/reactions_controller.rb @@ -0,0 +1,34 @@ +module Api + module V1 + class ReactionsController < ApiController + before_action :authenticate! + + def toggle + remove_count_cache_key + + result = ReactionToggle.toggle(params, current_user: current_user || @user) + + if result.success? + render json: { + result: result.action, + category: result.category, + id: result.reaction.id, + reactable_id: result.reaction.reactable_id, + reactable_type: result.reaction.reactable_type + } + else + render json: { error: result.errors_as_sentence, status: 422 }, status: :unprocessable_entity + end + end + + private + + # TODO: should this move to toggle service? refactor? + def remove_count_cache_key + return unless params[:reactable_type] == "Article" + + Rails.cache.delete "count_for_reactable-Article-#{params[:reactable_id]}" + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 8d673b739..3ceead8d8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,8 @@ Rails.application.routes.draw do put "/articles/:id/unpublish", to: "articles#unpublish", as: :article_unpublish put "/users/:id/unpublish", to: "users#unpublish", as: :user_unpublish + post "/reactions/toggle", to: "reactions#toggle" + draw :api end diff --git a/spec/requests/api/v1/docs/reactions_spec.rb b/spec/requests/api/v1/docs/reactions_spec.rb new file mode 100644 index 000000000..7622f33fb --- /dev/null +++ b/spec/requests/api/v1/docs/reactions_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" +require "swagger_helper" + +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable RSpec/VariableName + +RSpec.describe "api/v1/reactions", type: :request do + let(:Accept) { "application/vnd.forem.api-v1+json" } + let(:api_secret) { create(:api_secret) } + let(:category) { "like" } + let(:reactable) { create :article } + let(:reaction) { reactable.reactions.create user: user, category: "like" } + let(:result) { ReactionToggle::Result.new reaction: reaction } + let(:user) { api_secret.user } + + before do + result.category = category + allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) + allow(ReactionToggle).to receive(:toggle).and_return(result) + end + + path "/api/reactions/toggle" do + describe "post to create reaction" do + post("create reaction") do + tags "reactions" + description(<<-DESCRIBE.strip) + This endpoint allows the client to toggle the user's reaction to a specified reactable (eg, Article, Comment, or User). For examples: + * "Like"ing an Article will create a new "like" Reaction from the user for that Articles + * "Like"ing that Article a second time will remove the "like" from the user + DESCRIBE + + produces "application/json" + parameter name: :category, in: :query, required: true, schema: { + type: :string, + enum: Reaction::PUBLIC_CATEGORIES + } + parameter name: :reactable_id, in: :query, required: true, schema: { + type: :integer, + format: :int32 + } + parameter name: :reactable_type, in: :query, required: true, schema: { + type: :string, + enum: Reaction::REACTABLE_TYPES + } + + let(:reactable_id) { reactable.id } + let(:reactable_type) { "Article" } + + before do + result.action = "create" + end + + response(200, "successful") do + let(:"api-key") { api_secret.secret } + add_examples + + run_test! + end + + response "401", "unauthorized" do + let(:"api-key") { "invalid" } + add_examples + + run_test! + end + end + end + end +end + +# rubocop:enable RSpec/VariableName +# rubocop:enable RSpec/EmptyExampleGroup diff --git a/spec/requests/api/v1/reactions_spec.rb b/spec/requests/api/v1/reactions_spec.rb new file mode 100644 index 000000000..ca95c7d7b --- /dev/null +++ b/spec/requests/api/v1/reactions_spec.rb @@ -0,0 +1,73 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Reactions", type: :request do + let!(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } } + let(:params) do + { + reactable_type: "Article", + reactable_id: "123", + category: "like" + } + end + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + shared_context "when user is authorized" do + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:auth_header) { v1_headers.merge({ "api-key" => api_secret.secret }) } + end + + context "when unauthenticated and post to create" do + it "returns unauthorized" do + post api_reactions_toggle_path, params: params.to_json, headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized and post to create" do + it "returns unauthorized" do + post api_reactions_toggle_path, params: params.to_json, + headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized and post to create" do + include_context "when user is authorized" + + before do + allow(Rails.cache).to receive(:delete) + allow(ReactionToggle).to receive(:toggle).and_return(result) + end + + context "when toggled successfully" do + let(:result) { ReactionToggle::Result.new reaction: Reaction.new } + + it "responds with success" do + post api_reactions_toggle_path, params: params.to_json, headers: auth_header + expect(response).to have_http_status(:success) + end + + it "responds with expected JSON" do + post api_reactions_toggle_path, params: params.to_json, headers: auth_header + expect(JSON.parse(response.body).keys).to contain_exactly("id", "result", "category", "reactable_type", + "reactable_id") + end + end + + context "when toggled unsuccessfully" do + let(:result) do + ReactionToggle::Result.new.tap do |bad_result| + allow(bad_result).to receive(:success?).and_return(false) + allow(bad_result).to receive(:errors_as_sentence).and_return("Stuff was bad") + end + end + + it "responds with success" do + post api_reactions_toggle_path, params: params.to_json, headers: auth_header + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 25b059557..53ce1b774 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -3,7 +3,7 @@ "info": { "title": "Forem API V1", "version": "1.0.0", - "description": "Access Forem articles, users and other resources via API.\n For a real-world example of Forem in action, check out [DEV](https://www.dev.to).\n All endpoints can be accessed with the 'api-key' header and a accept header, but \n some of them are accessible publicly without authentication.\n\n Dates and date times, unless otherwise specified, must be in\n the [RFC 3339](https://tools.ietf.org/html/rfc3339) format." + "description": "Access Forem articles, users and other resources via API.\n For a real-world example of Forem in action, check out [DEV](https://www.dev.to).\n All endpoints can be accessed with the 'api-key' header and a accept header, but\n some of them are accessible publicly without authentication.\n\n Dates and date times, unless otherwise specified, must be in\n the [RFC 3339](https://tools.ietf.org/html/rfc3339) format." }, "paths": { "/api/articles": { @@ -103,45 +103,45 @@ "example": [ { "type_of": "article", - "id": 1357, - "title": "Ego Dominus Tuus168", - "description": "Blog pour-over taxidermy pbr&b bitters. Synth chia pork belly butcher. Mumblecore 8-bit banh mi...", - "readable_publish_date": "Aug 20", - "slug": "ego-dominus-tuus168-32d5", - "path": "/username372/ego-dominus-tuus168-32d5", - "url": "http://localhost:3000/username372/ego-dominus-tuus168-32d5", + "id": 4453, + "title": "East of Eden168", + "description": "Put a bird on it aesthetic narwhal xoxo pug. Disrupt slow-carb williamsburg crucifix...", + "readable_publish_date": "Aug 23", + "slug": "east-of-eden168-21ml", + "path": "/username372/east-of-eden168-21ml", + "url": "http://localhost:3000/username372/east-of-eden168-21ml", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2022-08-19T16:09:09Z", + "published_timestamp": "2022-08-23T12:08:21Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/2-1a96ae446ded018b65b215cce3aecc40a00e701642da521fdd6edd3c593ff6c1.png", - "social_image": "http://localhost:3000/assets/2-1a96ae446ded018b65b215cce3aecc40a00e701642da521fdd6edd3c593ff6c1.png", - "canonical_url": "http://localhost:3000/username372/ego-dominus-tuus168-32d5", - "created_at": "2022-08-19T16:09:09Z", + "cover_image": "http://localhost:3000/assets/24-377bc0861a9a539e8d1875ea9d4eea9e0226d93e1b6e9317e0c73c754699cc14.png", + "social_image": "http://localhost:3000/assets/24-377bc0861a9a539e8d1875ea9d4eea9e0226d93e1b6e9317e0c73c754699cc14.png", + "canonical_url": "http://localhost:3000/username372/east-of-eden168-21ml", + "created_at": "2022-08-23T12:08:21Z", "edited_at": null, "crossposted_at": null, - "published_at": "2022-08-19T16:09:09Z", - "last_comment_at": "2022-08-19T16:09:09Z", + "published_at": "2022-08-23T12:08:21Z", + "last_comment_at": "2022-08-23T12:08:21Z", "reading_time_minutes": 1, "tag_list": ["discuss"], "tags": "discuss", "user": { - "name": "Kyla \"Avery\" \\:/ Morissette", + "name": "Angelena \"Charis\" \\:/ Lang", "username": "username372", "twitter_username": "twitter372", "github_username": "github372", - "user_id": 3080, + "user_id": 10747, "website_url": null, - "profile_image": "/uploads/user/profile_image/3080/001a3d7d-c716-43aa-a46a-8c024888daf9.jpeg", - "profile_image_90": "/uploads/user/profile_image/3080/001a3d7d-c716-43aa-a46a-8c024888daf9.jpeg" + "profile_image": "/uploads/user/profile_image/10747/79dbb51d-a63a-4475-af29-a84fe417b9d8.jpeg", + "profile_image_90": "/uploads/user/profile_image/10747/79dbb51d-a63a-4475-af29-a84fe417b9d8.jpeg" }, "organization": { - "name": "Koss and Sons", + "name": "Vandervort-Sanford", "username": "org56", "slug": "org56", - "profile_image": "/uploads/organization/profile_image/507/464b6b16-79e1-4378-a9fc-76c81403f9e1.png", - "profile_image_90": "/uploads/organization/profile_image/507/464b6b16-79e1-4378-a9fc-76c81403f9e1.png" + "profile_image": "/uploads/organization/profile_image/1275/45556538-13d9-44af-af7e-c9fcdbe2617c.png", + "profile_image_90": "/uploads/organization/profile_image/1275/45556538-13d9-44af-af7e-c9fcdbe2617c.png" }, "flare_tag": { "name": "discuss", @@ -211,6 +211,69 @@ } } }, + "/api/reactions/toggle": { + "post": { + "summary": "create reaction", + "tags": ["reactions"], + "description": "This endpoint allows the client to toggle the user's reaction to a specified reactable (eg, Article, Comment, or User). For examples:\n * \"Like\"ing an Article will create a new \"like\" Reaction from the user for that Articles\n * \"Like\"ing that Article a second time will remove the \"like\" from the user", + "parameters": [ + { + "name": "category", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": ["like", "readinglist", "unicorn", "thinking", "hands"] + } + }, + { + "name": "reactable_id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "reactable_type", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": ["Comment", "Article", "User"] + } + } + ], + "responses": { + "200": { + "description": "successful", + "content": { + "application/json": { + "example": { + "result": "create", + "category": "like", + "id": 955, + "reactable_id": 4457, + "reactable_type": "Article" + } + } + } + }, + "401": { + "description": "unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + } + } + } + }, "/api/users/{id}/unpublish": { "put": { "summary": "Unpublish a User's Articles and Comments",