diff --git a/app/controllers/api/v0/articles_controller.rb b/app/controllers/api/v0/articles_controller.rb index e373d3f7e..c7616160e 100644 --- a/app/controllers/api/v0/articles_controller.rb +++ b/app/controllers/api/v0/articles_controller.rb @@ -18,7 +18,6 @@ module Api public_reactions_count created_at edited_at last_comment_at published updated_at video_thumbnail_url ].freeze - private_constant :INDEX_ATTRIBUTES_FOR_SERIALIZATION SHOW_ATTRIBUTES_FOR_SERIALIZATION = [ *INDEX_ATTRIBUTES_FOR_SERIALIZATION, :body_markdown, :processed_html diff --git a/app/controllers/api/v0/readinglist_controller.rb b/app/controllers/api/v0/readinglist_controller.rb new file mode 100644 index 000000000..ece02ac28 --- /dev/null +++ b/app/controllers/api/v0/readinglist_controller.rb @@ -0,0 +1,45 @@ +module Api + module V0 + class ReadinglistController < ApiController + before_action :authenticate! + before_action -> { doorkeeper_authorize! :public }, only: %w[index], if: -> { doorkeeper_token } + + INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION = %i[id reactable_id created_at status].freeze + private_constant :INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION + + PER_PAGE_MAX = 100 + private_constant :PER_PAGE_MAX + + def index + per_page = (params[:per_page] || 30).to_i + num = [per_page, PER_PAGE_MAX].min + + @readinglist = Reaction + .select(INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION) + .readinglist + .where(user_id: @user.id) + .where.not(status: "archived") + .order(created_at: :desc) + .page(params[:page]) + .per(num) + + articles = Article + .includes(:organization) + .select(ArticlesController::INDEX_ATTRIBUTES_FOR_SERIALIZATION) + .where(id: @readinglist.map(&:reactable_id)) + .decorate + + @users_by_id = User + .select(UsersController::SHOW_ATTRIBUTES_FOR_SERIALIZATION) + .find(articles.map(&:user_id)) + .index_by(&:id) + + articles_by_id = articles.index_by(&:id) + + @articles_by_reaction_ids = @readinglist.each_with_object({}) do |reaction, result| + result[reaction.id] = articles_by_id[reaction.reactable_id] + end + end + end + end +end diff --git a/app/controllers/api/v0/users_controller.rb b/app/controllers/api/v0/users_controller.rb index 03d81f709..34f1853c7 100644 --- a/app/controllers/api/v0/users_controller.rb +++ b/app/controllers/api/v0/users_controller.rb @@ -8,7 +8,6 @@ module Api id username name summary twitter_username github_username website_url location created_at profile_image registered ].freeze - private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION def show relation = User.select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) diff --git a/app/views/api/v0/readinglist/index.json.jbuilder b/app/views/api/v0/readinglist/index.json.jbuilder new file mode 100644 index 000000000..69e0d6e44 --- /dev/null +++ b/app/views/api/v0/readinglist/index.json.jbuilder @@ -0,0 +1,14 @@ +json.array! @readinglist do |reaction| + json.type_of "readinglist" + json.extract!(reaction, :id, :status) + json.created_at utc_iso_timestamp(reaction.created_at) + json.article do + article = @articles_by_reaction_ids[reaction.id] + json.partial! "api/v0/articles/article", article: article + json.tags article.cached_tag_list + json.partial! "api/v0/shared/user", user: @users_by_id[article.user_id] + if article.organization + json.partial! "api/v0/shared/organization", organization: article.organization + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 9b5d815c4..12b58ba28 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -176,6 +176,7 @@ Rails.application.routes.draw do get :users get :organizations end + resources :readinglist, only: [:index] resources :webhooks, only: %i[index create show destroy] resources :listings, only: %i[index show create update] diff --git a/docs/api_v0.yml b/docs/api_v0.yml index ea1a8d0e3..164487c9f 100644 --- a/docs/api_v0.yml +++ b/docs/api_v0.yml @@ -17,7 +17,7 @@ info: Dates and date times, unless otherwise specified, must be in the [RFC 3339](https://tools.ietf.org/html/rfc3339) format. - version: "0.8.1" + version: "0.9.0" termsOfService: https://dev.to/terms contact: name: DEV Team @@ -77,6 +77,17 @@ components: minimum: 1 maximum: 1000 default: 30 + perPageParam30to100: + in: query + name: per_page + required: false + description: Page size (the number of items to return per page). + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 30 perPageParam80to1000: in: query name: per_page @@ -829,6 +840,34 @@ components: type: string enum: [bump, publish, unpublish] + ReadingList: + type: object + required: + - type_of + - id + - status + - created_at + - article + properties: + type_of: + type: string + id: + description: Follow id + type: integer + format: int32 + status: + type: string + enum: + - valid + - invalid + - confirmed + - archived + created_at: + type: string + format: date-time + article: + $ref: "#/components/schemas/ArticleIndex" + PodcastEpisode: type: object required: @@ -1088,10 +1127,10 @@ components: readable_publish_date: Oct 24 social_image: https://res.cloudinary.com/practicaldev/image/fetch/s--SeMxdKIa--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://res.cloudinary.com/practicaldev/image/fetch/s--xU8cbIK4--/c_imagga_scale%2Cf_auto%2Cfl_progressive%2Ch_420%2Cq_auto%2Cw_1000/https://thepracticaldev.s3.amazonaws.com/i/8a39dzf3oovzc2snl7iv.png tag_list: - - meta - - changelog - - css - - ux + - meta + - changelog + - css + - ux tags: meta, changelog, css, ux slug: there-s-a-new-dev-theme-in-town-for-all-you-10x-hackers-out-there-plus-one-actually-useful-new-feature-2kgk path: "/devteam/there-s-a-new-dev-theme-in-town-for-all-you-10x-hackers-out-there-plus-one-actually-useful-new-feature-2kgk" @@ -1259,25 +1298,25 @@ components: profile_image: https://res.cloudinary.com/...png profile_image_90: https://res.cloudinary.com/...png children: - - type_of: comment - id_code: m35m - created_at: 2020-08-01T11:59:40Z - body_html: | - -
+ - type_of: comment + id_code: m35m + created_at: 2020-08-01T11:59:40Z + 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: [] + + 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 @@ -1285,24 +1324,24 @@ components: body_html:[deleted]
user: {} children: - - type_of: comment - id_code: m35m - body_html: | - - + - 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: [] + + 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 @@ -1310,24 +1349,24 @@ components: body_html:[hidden by post author]
user: {} children: - - type_of: comment - id_code: m35m - body_html: | - - + - 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: [] + + 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 @@ -1350,25 +1389,25 @@ components: profile_image: https://res.cloudinary.com/...png profile_image_90: https://res.cloudinary.com/...png children: - - type_of: comment - id_code: m35m - created_at: 2020-07-02T17:19:40Z - body_html: | - - + - type_of: comment + id_code: m35m + created_at: 2020-07-02T17:19:40Z + 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: [] + + 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 @@ -1376,24 +1415,24 @@ components: body_html:[deleted]
user: {} children: - - type_of: comment - id_code: m35m - body_html: | - - + - 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: [] + + 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 @@ -1401,24 +1440,24 @@ components: body_html:[hidden by post author]
user: {} children: - - type_of: comment - id_code: m35m - body_html: | - - + - 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: [] + + 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: [] Followers: value: @@ -1447,7 +1486,7 @@ components: us for our very first trip to Detroit MI! \n\nhttps://bit.ly/TBDetroit" tag_list: events tags: - - events + - events category: cfp processed_html: |Do you want to learn about automation? Maybe you're interested in AI-driven testing? Security testing? We have a selection of talks, workshops and training courses to help you in a wide variety of areas in software testing. Join us for our very first trip to Detroit MI!
@@ -1474,7 +1513,7 @@ components: us for our very first trip to Detroit MI! \n\nhttps://bit.ly/TBDetroit" tag_list: events tags: - - events + - events category: cfp processed_html: |Do you want to learn about automation? Maybe you're interested in AI-driven testing? Security testing? We have a selection of talks, workshops and training courses to help you in a wide variety of areas in software testing. Join us for our very first trip to Detroit MI!
@@ -1496,7 +1535,7 @@ components: body_markdown: Awesome conference category: cfp tags: - - events + - events ListingCreateOrganization: value: listing: @@ -1504,7 +1543,7 @@ components: body_markdown: Awesome conference category: cfp tags: - - events + - events organization_id: 1 ListingUpdate: value: @@ -1616,6 +1655,8 @@ tags: description: Listings are classified ads - name: podcast-episodes description: Podcast episodes + - name: readinglist + description: User's reading list - name: tags description: Tags for articles - name: users @@ -2808,6 +2849,50 @@ paths: -d '{"listing":{"title":"Title"}' \ https://dev.to/api/listings/{id} + /readinglist: + get: + operationId: getReadinglist + summary: User's reading list + description: | + This endpoint allows the client to retrieve a list of readinglist reactions along with the related article for the authenticated user. + + Reading list will be in reverse chronological order base + on the creation of the reaction. + + It will return paginated reading list items along with the articles + they refer to. By default a page will contain `30` items + tags: + - readinglist + parameters: + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/perPageParam30to100' + responses: + "200": + description: The reading list with a overwiew of the article + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ReadingList" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + examples: + error-unauthorized: + $ref: "#/components/examples/ErrorUnauthorized" + security: + - api_key: [] + - oauth2: [] + x-code-samples: + - lang: Shell + label: curl + source: | + curl -H "api-key: API_KEY" https://dev.to/api/readinglist + /podcast_episodes: get: operationId: getPodcastEpisodes diff --git a/spec/requests/api/v0/articles_spec.rb b/spec/requests/api/v0/articles_spec.rb index 24374588d..eeaab339f 100644 --- a/spec/requests/api/v0/articles_spec.rb +++ b/spec/requests/api/v0/articles_spec.rb @@ -481,7 +481,7 @@ RSpec.describe "Api::V0::Articles", type: :request do expect(response).to have_http_status(:ok) end - it "returns success when requesting publiched articles with public token" do + it "returns success when requesting published articles with public token" do public_token = create(:doorkeeper_access_token, resource_owner: user, scopes: "public") get me_api_articles_path(status: :published), params: { access_token: public_token.token } expect(response.media_type).to eq("application/json") diff --git a/spec/requests/api/v0/readinglist_spec.rb b/spec/requests/api/v0/readinglist_spec.rb new file mode 100644 index 000000000..7958af630 --- /dev/null +++ b/spec/requests/api/v0/readinglist_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "Api::V0::ReadingList", type: :request do + describe "GET /api/readinglist" do + let(:readinglist) { create_list(:reading_reaction, 3, user: user) } + + context "when request is unauthenticated" do + it "return unauthorized" do + get api_readinglist_index_path + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + let(:access_token) { create :doorkeeper_access_token, resource_owner: user, scopes: "public read_articles" } + let(:user) { create(:user) } + + it "works with bearer authorization" do + headers = { "authorization" => "Bearer #{access_token.token}", "content-type" => "application/json" } + + get api_readinglist_index_path, headers: headers + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "returns proper response specification" do + get api_readinglist_index_path, params: { access_token: access_token.token } + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "doesn't return archived reactions" do + create(:reading_reaction, user: user) + create(:reading_reaction, user: user, status: :archived) + get api_readinglist_index_path, params: { access_token: access_token.token } + expect(response.parsed_body.length).to eq(1) + end + + it "supports pagination" do + create_list(:reading_reaction, 3, user: user) + get api_readinglist_index_path, params: { access_token: access_token.token, page: 2, per_page: 2 } + expect(response.parsed_body.length).to eq(1) + end + end + end +end