Comments API: hide deleted and hidden comments (plus docs) (#6482) [deploy]

* Modernize index test

* Hide comments body_html and user info if the comment is either deleted or hidden

* Shorten lines a bit

* Freeze repeated strings

* Document all /comments

* Document /comments/{id}
This commit is contained in:
rhymes 2020-03-07 09:02:10 +01:00 committed by GitHub
parent 9000a42de3
commit 21fca3e0f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 568 additions and 71 deletions

View file

@ -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

View file

@ -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("&#39;", "'").gsub("&amp;", "&")

View file

@ -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 "<p>#{Comment::TITLE_DELETED}</p>"
json.set! :user, {}
elsif comment.hidden_by_commentable_user?
json.body_html "<p>#{Comment::TITLE_HIDDEN}</p>"
json.set! :user, {}
else
json.body_html comment.processed_html
json.partial! "api/v0/shared/user", user: comment.user
end

View file

@ -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: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
<p>...</p>
</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: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: <p>[deleted]</p>
user: {}
children:
- type_of: comment
id_code: m35m
body_html: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: <p>[hidden by post author]</p>
user: {}
children:
- type_of: comment
id_code: m35m
body_html: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
<p>...</p>
</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: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: <p>[deleted]</p>
user: {}
children:
- type_of: comment
id_code: m35m
body_html: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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: <p>[hidden by post author]</p>
user: {}
children:
- type_of: comment
id_code: m35m
body_html: |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>...</p>
</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

View file

@ -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("<p>#{Comment::TITLE_DELETED}</p>")
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("<p>#{Comment::TITLE_HIDDEN}</p>")
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("<p>[deleted]</p>")
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("<p>[hidden by post author]</p>")
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