diff --git a/app/controllers/api/v0/comments_controller.rb b/app/controllers/api/v0/comments_controller.rb index 92fc4619c..79f4b589b 100644 --- a/app/controllers/api/v0/comments_controller.rb +++ b/app/controllers/api/v0/comments_controller.rb @@ -27,7 +27,9 @@ module Api set_surrogate_key_header Comment.table_key, edge_cache_keys(tree_with_root_comment) end - ATTRIBUTES_FOR_SERIALIZATION = %i[id processed_html user_id ancestry].freeze + ATTRIBUTES_FOR_SERIALIZATION = %i[ + id processed_html user_id ancestry deleted hidden_by_commentable_user + ].freeze private_constant :ATTRIBUTES_FOR_SERIALIZATION private diff --git a/app/models/comment.rb b/app/models/comment.rb index 50d81b2cb..5e36fd7a2 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,7 +1,11 @@ class Comment < ApplicationRecord + TITLE_DELETED = "[deleted]".freeze + TITLE_HIDDEN = "[hidden by post author]".freeze + has_ancestry resourcify include Reactable + belongs_to :commentable, polymorphic: true, optional: true counter_culture :commentable belongs_to :user @@ -76,8 +80,8 @@ class Comment < ApplicationRecord end def title(length = 80) - return "[deleted]" if deleted - return "[hidden by post author]" if hidden_by_commentable_user + return TITLE_DELETED if deleted + return TITLE_HIDDEN if hidden_by_commentable_user text = ActionController::Base.helpers.strip_tags(processed_html).strip truncated_text = ActionController::Base.helpers.truncate(text, length: length).gsub("'", "'").gsub("&", "&") diff --git a/app/views/api/v0/comments/_comment.json.jbuilder b/app/views/api/v0/comments/_comment.json.jbuilder index c73822823..025ab985f 100644 --- a/app/views/api/v0/comments/_comment.json.jbuilder +++ b/app/views/api/v0/comments/_comment.json.jbuilder @@ -1,6 +1,13 @@ -json.type_of "comment" +json.type_of "comment" +json.id_code comment.id_code_generated -json.id_code comment.id_code_generated -json.body_html comment.processed_html - -json.partial! "api/v0/shared/user", user: comment.user +if comment.deleted? + json.body_html "
#{Comment::TITLE_DELETED}
" + json.set! :user, {} +elsif comment.hidden_by_commentable_user? + json.body_html "#{Comment::TITLE_HIDDEN}
" + json.set! :user, {} +else + json.body_html comment.processed_html + json.partial! "api/v0/shared/user", user: comment.user +end diff --git a/docs/api_v0.yml b/docs/api_v0.yml index 0bf81b50a..fc831a8ff 100644 --- a/docs/api_v0.yml +++ b/docs/api_v0.yml @@ -8,7 +8,7 @@ info: Dates and date times, unless otherwise specified, must be in the [RFC 3339](https://tools.ietf.org/html/rfc3339) format. - version: "0.6.2" + version: "0.6.3" termsOfService: https://dev.to/terms contact: name: DEV Team @@ -62,6 +62,7 @@ components: tokenUrl: https://dev.to/oauth/token refreshUrl: https://dev.to/oauth/token scopes: {} + schemas: APIError: type: object @@ -484,6 +485,29 @@ components: description: Text color (hexadecimal) type: string + Comment: + type: object + required: + - type_of + - id_code + - body_html + - user + - children + properties: + type_of: + type: string + id_code: + type: string + body_html: + description: HTML formatted comment + type: string + user: + $ref: "#/components/schemas/SharedUser" + children: + type: array + items: + $ref: "#/components/schemas/Comment" + Listing: type: object required: @@ -587,6 +611,7 @@ components: description: Set it to "draft" to create an unpublished listing type: string enum: [draft] + ListingUpdate: type: object properties: @@ -641,7 +666,6 @@ components: type: string enum: [bump, publish, unpublish] - SharedUser: description: The resource creator type: object @@ -706,6 +730,30 @@ components: items: type: string + WebhookIndex: + description: Webhook + type: object + properties: + type_of: + type: string + id: + type: integer + format: int64 + source: + description: The name of the requester, eg. "DEV" + type: string + target_url: + type: string + format: url + events: + description: An array of events identifiers + type: array + items: + type: string + created_at: + type: string + format: date-time + WebhookShow: description: Webhook type: object @@ -732,30 +780,6 @@ components: user: $ref: "#/components/schemas/SharedUser" - WebhookIndex: - description: Webhook - type: object - properties: - type_of: - type: string - id: - type: integer - format: int64 - source: - description: The name of the requester, eg. "DEV" - type: string - target_url: - type: string - format: url - events: - description: An array of events identifiers - type: array - items: - type: string - created_at: - type: string - format: date-time - examples: ErrorBadRequest: value: @@ -909,6 +933,202 @@ components: canonical_url: https://example.com/blog/hello organization_id: 1234 + Comments: + value: + - type_of: comment + id_code: m3m0 + body_html: | + + +...
+ + + user: + name: Heriberto Morissette + username: heriberto_morissette + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/...jpeg + children: [] + - type_of: comment + id_code: m357 + body_html: | + + +...
+ +...
+ + + user: + name: Dario Waelchi + username: dario waelchi + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...png + profile_image_90: https://res.cloudinary.com/...png + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/practicaldev/image/fetch/s--SC90PuMi--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/2693/146201.jpeg + children: [] + CommentsDeleted: + value: + - type_of: comment + id_code: m357 + body_html:[deleted]
+ user: {} + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/practicaldev/image/fetch/s--SC90PuMi--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/2693/146201.jpeg + children: [] + CommentsHidden: + value: + - type_of: comment + id_code: m357 + body_html:[hidden by post author]
+ user: {} + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/practicaldev/image/fetch/s--SC90PuMi--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/2693/146201.jpeg + children: [] + Comment: + value: + type_of: comment + id_code: m357 + body_html: | + + +...
+ +...
+ + + user: + name: Dario Waelchi + username: dario waelchi + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...png + profile_image_90: https://res.cloudinary.com/...png + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/....jpeg + children: [] + CommentDeleted: + value: + type_of: comment + id_code: m357 + body_html:[deleted]
+ user: {} + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/...jpeg + children: [] + CommentHidden: + value: + type_of: comment + id_code: m357 + body_html:[hidden by post author]
+ user: {} + children: + - type_of: comment + id_code: m35m + body_html: | + + + +...
+ + + user: + name: rhymes + username: rhymes + twitter_username: + github_username: + website_url: + profile_image: https://res.cloudinary.com/...jpeg + profile_image_90: https://res.cloudinary.com/...jpeg + children: [] + Listings: value: - type_of: classified_listing @@ -1026,6 +1246,8 @@ components: tags: - name: articles description: Articles are all the posts users create on DEV + - name: comments + description: Users can leave comments to articles and podcasts episodes - name: listings description: Listings are classified ads - name: users @@ -1595,6 +1817,108 @@ paths: source: | curl -H "api-key: API_KEY" https://dev.to/api/articles/me/all + /comments: + get: + operationId: getCommentsByArticleId + summary: Comments + description: | + This endpoint allows the client to retrieve all comments belonging to an + article as threaded conversations. + + It will return the all top level comments with their nested comments as + threads. See the format specification for further details. + tags: + - comments + parameters: + - name: a_id + in: query + description: Article identifier. + schema: + type: integer + format: int32 + example: 270180 + responses: + 200: + description: A list of threads of comments + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Comment" + examples: + comments-success: + $ref: "#/components/examples/Comments" + comments-success-deleted: + $ref: "#/components/examples/CommentsDeleted" + comments-success-hidden: + $ref: "#/components/examples/CommentsHidden" + 404: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + article-not-found: + $ref: "#/components/examples/ErrorNotFound" + x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples + - lang: Shell + label: curl (all comments of an article) + source: | + curl https://dev.to/api/comments?a_id=270180 + + /comments/{id}: + get: + operationId: getCommentById + summary: Comment + description: | + This endpoint allows the client to retrieve a comment as well as his + descendants comments. + + It will return the required comment (the root) with its nested + descendants as a thread. + + See the format specification for further details. + tags: + - comments + parameters: + - name: id + in: path + required: true + description: Comment identifier. + schema: + type: string + example: m35m + responses: + 200: + description: A comment and its descendants + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + examples: + comment-success: + $ref: "#/components/examples/Comment" + comments-success-deleted: + $ref: "#/components/examples/CommentDeleted" + comments-success-hidden: + $ref: "#/components/examples/CommentHidden" + 404: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + article-not-found: + $ref: "#/components/examples/ErrorNotFound" + x-code-samples: # https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-code-samples + - lang: Shell + label: curl (a comment and its descendants) + source: | + curl https://dev.to/api/comments/m51e + /listings: get: operationId: getListings diff --git a/spec/requests/api/v0/comments_spec.rb b/spec/requests/api/v0/comments_spec.rb index 53fcb7d60..ee6c7df80 100644 --- a/spec/requests/api/v0/comments_spec.rb +++ b/spec/requests/api/v0/comments_spec.rb @@ -1,58 +1,93 @@ require "rails_helper" RSpec.describe "Api::V0::Comments", type: :request do - def json_response - JSON.parse(response.body) - end - let_it_be(:article) { create(:article) } let_it_be(:root_comment) { create(:comment, commentable: article) } - let_it_be(:child_comment) { create(:comment, commentable: article, parent: root_comment) } - let_it_be(:grandchild_comment) { create(:comment, commentable: article, parent: child_comment) } - let_it_be(:great_grandchild_comment) { create(:comment, commentable: article, parent: grandchild_comment) } + let_it_be_changeable(:child_comment) do + create(:comment, commentable: article, parent: root_comment) + end + let_it_be(:grandchild_comment) do + create(:comment, commentable: article, parent: child_comment) + end + let_it_be(:great_grandchild_comment) do + create(:comment, commentable: article, parent: grandchild_comment) + end + + def find_root_comment(response) + response.parsed_body.detect do |cm| + cm["id_code"] == root_comment.id_code_generated + end + end + + def find_child_comment(response, action = :index) + body = response.parsed_body + + root_comment_json = if action == :index + body.detect do |cm| + cm["id_code"] == root_comment.id_code_generated + end + else + body + end + + root_comment_json["children"].detect do |cm| + cm["id_code"] == child_comment["id_code"] + end + end describe "GET /api/comments" do it "returns not found if wrong article id" do - get "/api/comments?a_id=gobbledygook" + get api_comments_path(a_id: "gobbledygook") + expect(response).to have_http_status(:not_found) end it "returns comments for article" do - get "/api/comments?a_id=#{article.id}" - expect(json_response.size).to eq(1) + get api_comments_path(a_id: article.id) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.size).to eq(1) end it "does not include children comments in the root list" do - get "/api/comments?a_id=#{article.id}" + get api_comments_path(a_id: article.id) + expected_ids = article.comments.roots.map(&:id_code_generated) - expect(json_response.map { |cm| cm["id_code"] }).to match_array(expected_ids) + response_ids = response.parsed_body.map { |cm| cm["id_code"] } + expect(response_ids).to match_array(expected_ids) end it "includes children comments in the children list" do - get "/api/comments?a_id=#{article.id}" - comment_with_children = json_response.detect { |cm| cm["id_code"] == root_comment.id_code_generated } - expect(comment_with_children["children"][0]["id_code"]).to eq(child_comment.id_code_generated) + get api_comments_path(a_id: article.id) + + child_comment_json = find_child_comment(response) + expect(child_comment_json["id_code"]).to eq(child_comment.id_code_generated) end it "includes grandchildren comments in the children-children list" do - get "/api/comments?a_id=#{article.id}" + get api_comments_path(a_id: article.id) - comment_with_descendants = json_response.detect { |cm| cm["id_code"] == root_comment.id_code_generated } - expect(comment_with_descendants["children"][0]["children"][0]["id_code"]).to eq(grandchild_comment.id_code_generated) + root_comment_json = find_root_comment(response) + grandchild_comment_json_id = root_comment_json.dig( + "children", 0, "children", 0, "id_code" + ) + expect(grandchild_comment_json_id).to eq(grandchild_comment.id_code_generated) end it "includes great-grandchildren comments in the children-children-children list" do - get "/api/comments?a_id=#{article.id}" + get api_comments_path(a_id: article.id) - comment_with_descendants = json_response.detect { |cm| cm["id_code"] == root_comment.id_code_generated } - json_great_grandchild_id_code = comment_with_descendants["children"][0]["children"][0]["children"][0]["id_code"] - expect(json_great_grandchild_id_code).to eq(great_grandchild_comment.id_code_generated) + root_comment_json = find_root_comment(response) + great_grandchild_comment_json_id = root_comment_json.dig( + "children", 0, "children", 0, "children", 0, "id_code" + ) + expect(great_grandchild_comment_json_id).to eq(great_grandchild_comment.id_code_generated) end it "sets the correct edge caching surrogate key for all the comments" do sibling_root_comment = create(:comment, commentable: article) - get "/api/comments?a_id=#{article.id}" + get api_comments_path(a_id: article.id) expected_key = [ article.record_key, "comments", sibling_root_comment.record_key, @@ -61,43 +96,108 @@ RSpec.describe "Api::V0::Comments", type: :request do ].to_set expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) end + + context "when a comment is deleted" do + before do + child_comment.update(deleted: true) + end + + it "appears in the thread" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["body_html"]).to eq("#{Comment::TITLE_DELETED}
") + end + + it "does not render the user information" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["user"]).to be_empty + end + + it "still has children comments" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["children"]).not_to be_empty + end + end + + context "when a comment is hidden" do + before do + child_comment.update(hidden_by_commentable_user: true) + end + + it "appears in the thread" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["body_html"]).to eq("#{Comment::TITLE_HIDDEN}
") + end + + it "does not render the user information" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["user"]).to be_empty + end + + it "still has children comments" do + get api_comments_path(a_id: article.id) + + expect(find_child_comment(response)["children"]).not_to be_empty + end + end end describe "GET /api/comments/:id" do it "returns not found if wrong comment id" do - get "/api/comments/foobar" + get api_comment_path("foobar") + expect(response).to have_http_status(:not_found) end it "returns the comment" do - get "/api/comments/#{root_comment.id_code_generated}" - expect(json_response["id_code"]).to eq(root_comment.id_code_generated) + get api_comment_path(root_comment.id_code_generated) + expect(response).to have_http_status(:ok) + + expect(response.parsed_body["id_code"]).to eq(root_comment.id_code_generated) end it "includes children comments in the children list" do - get "/api/comments/#{root_comment.id_code_generated}" + get api_comment_path(root_comment.id_code_generated) - comment_with_children = json_response - expect(comment_with_children["children"][0]["id_code"]).to eq(child_comment.id_code_generated) + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) end it "includes grandchildren comments in the children-children list" do - get "/api/comments/#{root_comment.id_code_generated}" + get api_comment_path(root_comment.id_code_generated) - comment_with_descendants = json_response - expect(comment_with_descendants["children"][0]["children"][0]["id_code"]).to eq(grandchild_comment.id_code_generated) + grandchild_comment_json_id = response.parsed_body.dig( + "children", 0, "children", 0, "id_code" + ) + expect(grandchild_comment_json_id).to eq(grandchild_comment.id_code_generated) end it "includes great-grandchildren comments in the children-children-children list" do - get "/api/comments/#{root_comment.id_code_generated}" + get api_comment_path(root_comment.id_code_generated) - comment_with_descendants = json_response - json_great_grandchild_id_code = comment_with_descendants["children"][0]["children"][0]["children"][0]["id_code"] - expect(json_great_grandchild_id_code).to eq(great_grandchild_comment.id_code_generated) + great_grandchild_comment_json_id = response.parsed_body.dig( + "children", 0, "children", 0, "children", 0, "id_code" + ) + expect(great_grandchild_comment_json_id).to eq(great_grandchild_comment.id_code_generated) end it "sets the correct edge caching surrogate key for all the comments" do - get "/api/comments/#{root_comment.id_code_generated}" + get api_comment_path(root_comment.id_code_generated) expected_key = [ "comments", root_comment.record_key, child_comment.record_key, @@ -105,5 +205,65 @@ RSpec.describe "Api::V0::Comments", type: :request do ].to_set expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) end + + context "when a comment is deleted" do + before do + child_comment.update(deleted: true) + end + + it "appears in the thread" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["body_html"]).to eq("[deleted]
") + end + + it "does not render the user information" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["user"]).to be_empty + end + + it "still has children comments" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["children"]).not_to be_empty + end + end + + context "when a comment is hidden" do + before do + child_comment.update(hidden_by_commentable_user: true) + end + + it "appears in the thread" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["body_html"]).to eq("[hidden by post author]
") + end + + it "does not render the user information" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["user"]).to be_empty + end + + it "still has children comments" do + get api_comment_path(root_comment.id_code_generated) + + expect(find_child_comment(response, :show)["children"]).not_to be_empty + end + end end end