diff --git a/app/controllers/api/v0/followers_controller.rb b/app/controllers/api/v0/followers_controller.rb index 083a7512b..1e471260f 100644 --- a/app/controllers/api/v0/followers_controller.rb +++ b/app/controllers/api/v0/followers_controller.rb @@ -1,11 +1,13 @@ module Api module V0 class FollowersController < ApiController + include JsonApiSortParam + before_action :authenticate_with_api_key_or_current_user! before_action -> { limit_per_page(default: 80, max: 1000) } USERS_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id follower_id follower_type + id follower_id follower_type created_at ].freeze private_constant :USERS_ATTRIBUTES_FOR_SERIALIZATION @@ -13,7 +15,7 @@ module Api @follows = Follow.followable_user(@user.id) .includes(:follower) .select(USERS_ATTRIBUTES_FOR_SERIALIZATION) - .order(created_at: :desc) + .order(order_criteria) .page(params[:page]) .per(@follows_limit) end @@ -24,6 +26,14 @@ module Api per_page = (params[:per_page] || default).to_i @follows_limit = [per_page, max].min end + + def order_criteria + parse_sort_param( + params[:sort], + allowed_fields: [:created_at], + default_sort: { created_at: :desc }, + ) + end end end end diff --git a/app/controllers/concerns/json_api_sort_param.rb b/app/controllers/concerns/json_api_sort_param.rb new file mode 100644 index 000000000..c234ddb33 --- /dev/null +++ b/app/controllers/concerns/json_api_sort_param.rb @@ -0,0 +1,32 @@ +module JsonApiSortParam + # Handles JSON API style sort params + # + # @param param_string [String] The unmodified request param + # @param allowed_fields [Array] Fields that can be sorted by. This + # array determines the sort order of the resulting hash. + # @param default_sort [{Symbol => Symbol}] The default sort order. Used when + # the param string is nil/empty or when parsing it results in an empty hash. + def parse_sort_param(param_string, allowed_fields:, default_sort:) + fields = param_string.to_s.split(",") + unfiltered_hash = fields_to_hash(fields) + sort = sort_and_filter(unfiltered_hash, allowed_fields) + sort.presence || default_sort + end + + private + + def fields_to_hash(fields) + fields.each_with_object({}) do |field, result| + if field.start_with?("-") + result[field[1..]] = :desc + else + result[field] = :asc + end + end.symbolize_keys + end + + def sort_and_filter(fields_hash, allowed_fields) + field_order = allowed_fields.each_with_index.to_h + fields_hash.slice(*allowed_fields).sort_by { |k, _v| field_order[k] }.to_h + end +end diff --git a/app/views/api/v0/followers/users.json.jbuilder b/app/views/api/v0/followers/users.json.jbuilder index 2ea797350..16ce2c9e9 100644 --- a/app/views/api/v0/followers/users.json.jbuilder +++ b/app/views/api/v0/followers/users.json.jbuilder @@ -1,6 +1,7 @@ json.array! @follows do |follow| - json.type_of "user_follower" - json.id follow.id + json.type_of "user_follower" + json.id follow.id + json.created_at utc_iso_timestamp(follow.created_at) json.partial! "api/v0/shared/follows", user: follow.follower end diff --git a/docs/api_v0.yml b/docs/api_v0.yml index 0e4b96929..f1fc2327f 100644 --- a/docs/api_v0.yml +++ b/docs/api_v0.yml @@ -687,6 +687,7 @@ components: type: object required: - type_of + - created_at - id - name - path @@ -695,6 +696,10 @@ components: properties: type_of: type: string + created_at: + description: Date the user became a follower + type: string + format: date-time id: description: Follow id type: integer @@ -1552,12 +1557,14 @@ components: value: - type_of: user_follower id: 12 + created_at: "2021-04-02T04:21:46Z" name: Mrs. Neda Morissette path: "/nedamrsmorissette" username: nedamrsmorissette profile_image: https://res.cloudinary.com/... - type_of: user_follower id: 11 + created_at: "2021-04-02T04:21:46Z" name: Yoko Hintz path: "/yokohintz" username: yokohintz @@ -1567,6 +1574,7 @@ components: value: - type_of: listing id: 1157 + created_at: "2021-04-07 08:29:42 UTC" title: TestBash Detroit slug: testbash-detroit-50gb body_markdown: "Do you want to learn about automation? Maybe you're interested in @@ -2166,7 +2174,7 @@ paths: - $ref: '#/components/parameters/perPageParam30to1000' responses: "200": - description: A list of articles sorted by descending publish date + description: A list of articles sorted by descending publish date content: application/json: schema: @@ -2709,6 +2717,17 @@ paths: parameters: - $ref: '#/components/parameters/pageParam' - $ref: '#/components/parameters/perPageParam80to1000' + - in: query + name: sort + schema: + type: string + default: 'created_at' + pattern: '^-?\w+(,-?\w+)*$' + required: false + description: | + Specifies the sort order for the `created_at` param of the follow + relationship. To sort by newest followers first (descending order) + specify `?sort=-created_at`. responses: "200": description: A list of followers diff --git a/spec/controllers/concerns/json_api_sort_param_spec.rb b/spec/controllers/concerns/json_api_sort_param_spec.rb new file mode 100644 index 000000000..1470540b3 --- /dev/null +++ b/spec/controllers/concerns/json_api_sort_param_spec.rb @@ -0,0 +1,42 @@ +require "rails_helper" + +RSpec.describe JsonApiSortParam do + let!(:controller) { Class.new { include JsonApiSortParam }.new } + + it "returns a default sort order if there are no sorting params" do + params = controller.parse_sort_param( + "", + allowed_fields: [:created_at], + default_sort: { created_at: :desc }, + ) + expect(params).to eq({ created_at: :desc }) + end + + it "filters out non-allowed params", :aggregate_failures do + params = controller.parse_sort_param( + "-created_at,updated_at", + allowed_fields: [:updated_at], + default_sort: { created_at: :desc }, + ) + expect(params).not_to have_key(:created_at) + expect(params).to have_key(:updated_at) + end + + it "generates a sort params hash" do + params = controller.parse_sort_param( + "created_at,-updated_at", + allowed_fields: %i[created_at updated_at], + default_sort: { created_at: :desc }, + ) + expect(params).to eq({ created_at: :asc, updated_at: :desc }) + end + + it "sorts the params in the correct order" do + params = controller.parse_sort_param( + "-updated_at,created_at", + allowed_fields: %i[created_at updated_at], + default_sort: { created_at: :desc }, + ) + expect(params.to_a).to eq([%i[created_at asc], %i[updated_at desc]]) + end +end diff --git a/spec/requests/api/v0/followers_spec.rb b/spec/requests/api/v0/followers_spec.rb index 8c9285ee9..75c27b0f5 100644 --- a/spec/requests/api/v0/followers_spec.rb +++ b/spec/requests/api/v0/followers_spec.rb @@ -43,6 +43,7 @@ RSpec.describe "Api::V0::FollowersController", type: :request do expect(response_follower["path"]).to eq(follower.path) expect(response_follower["username"]).to eq(follower.username) expect(response_follower["profile_image"]).to eq(Images::Profile.call(follower.profile_image_url, length: 60)) + expect(response_follower["created_at"]).to be_an_instance_of(String) end it "supports pagination" do @@ -58,12 +59,21 @@ RSpec.describe "Api::V0::FollowersController", type: :request do expect(response.parsed_body.length).to eq(0) end - it "order results for reverse following date" do + it "orders results by descending following date by default" do follower2.follow(user) - follows = user.followings.order(id: :desc).last(2).map(&:id) - get api_followers_users_path, headers: headers + + follows = user.followings.order(id: :desc).last(2).map(&:id) + result = response.parsed_body.map { |f| f["id"] } + expect(result).to eq(follows) + end + + it "orders results by ascending following date if the 'sort' param is specified" do + follower2.follow(user) + + follows = user.followings.order(id: :asc).last(2).map(&:id) + get api_followers_users_path, headers: headers, params: { sort: "created_at" } result = response.parsed_body.map { |f| f["id"] } expect(result).to eq(follows) end