From d7750f39be570de49d4bfb7b8dded8e24ba529d4 Mon Sep 17 00:00:00 2001 From: Andre Gomes Date: Fri, 9 Feb 2024 14:25:33 -0300 Subject: [PATCH 1/5] feat: add PaginateService --- .rubocop.yml | 3 ++ app/services/paginate_service.rb | 37 ++++++++++++++++++++++++ spec/services/paginate_service_spec.rb | 39 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 app/services/paginate_service.rb create mode 100644 spec/services/paginate_service_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 76366c4..4236077 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,3 +13,6 @@ Style/Documentation: RSpec/EmptyExampleGroup: Exclude: - "**/*swagger_spec.rb" + +RSpec/NestedGroups: + Max: 4 diff --git a/app/services/paginate_service.rb b/app/services/paginate_service.rb new file mode 100644 index 0000000..a7b3b9f --- /dev/null +++ b/app/services/paginate_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class PaginateService + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 20 + MAX_PER_PAGE = 100 + + def initialize(relation:, page: DEFAULT_PAGE, per_page: DEFAULT_PER_PAGE) + @relation = relation + @page = page + @per_page = [per_page, MAX_PER_PAGE].min + end + + def call + @relation.offset(offset).limit(per_page) + end + + def meta + { page: page, per_page: per_page, total_entries: total_entries, total_pages: total_pages } + end + + private + + attr_reader :relation, :page, :per_page + + def offset + (page - 1) * per_page + end + + def total_pages + (total_entries.to_f / per_page).ceil + end + + def total_entries + @total_entries ||= relation.offset(nil).limit(nil).count + end +end diff --git a/spec/services/paginate_service_spec.rb b/spec/services/paginate_service_spec.rb new file mode 100644 index 0000000..3bfdf1a --- /dev/null +++ b/spec/services/paginate_service_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe PaginateService do + describe '#call' do + subject(:paginate_service) { described_class.new(relation: relation, page: page, per_page: per_page) } + + let(:relation) { Candidate.all } + let(:page) { 1 } + let(:per_page) { 1 } + + context 'when there are records of the given relation' do + before { create_list(:candidate, 2) } + + it 'returns first page of results' do + expect(paginate_service.call.order(:id).map(&:attributes)).to eq([Candidate.first.attributes]) + end + + it 'returns only the number of records specified by per_page' do + expect(paginate_service.call.count).to eq(1) + end + + context 'when on second page' do + let(:page) { 2 } + + it 'returns second page of results' do + expect(paginate_service.call.order(:id).map(&:attributes)).to eq([Candidate.last.attributes]) + end + end + end + + context 'when the relation is empty' do + let(:relation) { Candidate.all } + + it 'returns the relation' do + expect(paginate_service.call).to eq([]) + end + end + end +end From 8bd21b3c2e1134aadec04c892a8eab8ba9ccd500 Mon Sep 17 00:00:00 2001 From: Andre Gomes Date: Fri, 9 Feb 2024 17:09:58 -0300 Subject: [PATCH 2/5] deps: add gem 'oj' for json serialization performance --- Gemfile | 2 +- Gemfile.lock | 8 ++++---- config/initializers/oj.rb | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 config/initializers/oj.rb diff --git a/Gemfile b/Gemfile index 38e6d89..d2b713f 100644 --- a/Gemfile +++ b/Gemfile @@ -6,8 +6,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '2.7.7' gem 'bootsnap', '>= 1.4.4', require: false -gem 'jbuilder', '~> 2.7' gem 'mysql2', '~> 0.5' +gem 'oj' gem 'puma', '~> 5.0' gem 'rails', '~> 6.1.7', '>= 6.1.7.3' gem 'rswag' diff --git a/Gemfile.lock b/Gemfile.lock index 3e65c0e..0b2a72c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -63,6 +63,7 @@ GEM addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) + bigdecimal (3.1.6) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -85,9 +86,6 @@ GEM activesupport (>= 5.0) i18n (1.12.0) concurrent-ruby (~> 1.0) - jbuilder (2.11.5) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) json (2.6.3) json-schema (4.1.1) addressable (>= 2.8) @@ -118,6 +116,8 @@ GEM nio4r (2.5.8) nokogiri (1.14.2-x86_64-linux) racc (~> 1.4) + oj (3.16.3) + bigdecimal (>= 3.0) parallel (1.22.1) parser (3.2.1.1) ast (~> 2.4.1) @@ -275,8 +275,8 @@ DEPENDENCIES byebug factory_bot_rails (~> 6.0.0) faker (~> 3.2) - jbuilder (~> 2.7) mysql2 (~> 0.5) + oj pry-byebug puma (~> 5.0) rack-mini-profiler (~> 2.0) diff --git a/config/initializers/oj.rb b/config/initializers/oj.rb new file mode 100644 index 0000000..64598c5 --- /dev/null +++ b/config/initializers/oj.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Optimize JSON serialization https://github.com/ohler55/oj/blob/develop/pages/Rails.md +Oj.optimize_rails From 7112fd02215a96082bc0a5b645afbaf02fabd04d Mon Sep 17 00:00:00 2001 From: Andre Gomes Date: Fri, 9 Feb 2024 17:11:02 -0300 Subject: [PATCH 3/5] lint: update rubocop configs and lint --- .rubocop.yml | 10 ++++++++ app/services/paginate_service.rb | 26 +++++++++++++++------ app/use_cases/candidates/create_use_case.rb | 4 ++-- spec/swagger_helper.rb | 8 +++---- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 4236077..81f41a0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,3 +16,13 @@ RSpec/EmptyExampleGroup: RSpec/NestedGroups: Max: 4 + +RSpec/ExampleLength: + Enabled: false + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table diff --git a/app/services/paginate_service.rb b/app/services/paginate_service.rb index a7b3b9f..b983615 100644 --- a/app/services/paginate_service.rb +++ b/app/services/paginate_service.rb @@ -1,22 +1,34 @@ # frozen_string_literal: true +# PaginateService is responsible for paginating a relation and returning metadata about the pagination. class PaginateService DEFAULT_PAGE = 1 DEFAULT_PER_PAGE = 20 MAX_PER_PAGE = 100 - def initialize(relation:, page: DEFAULT_PAGE, per_page: DEFAULT_PER_PAGE) + # Initializes a new instance of PaginateService. + # + # @param relation [Object] The relation object to paginate. + # @param page [Integer] The page number to retrieve (default: 1). + # @param per_page [Integer] The number of records per page (default: 20). + def initialize(relation:, page: nil, per_page: nil) @relation = relation - @page = page - @per_page = [per_page, MAX_PER_PAGE].min + @page = page || DEFAULT_PAGE + @per_page = [per_page || DEFAULT_PER_PAGE, MAX_PER_PAGE].min end + # Paginates the relation based on the specified page and per_page values. + # + # @return [Object] The paginated relation. def call @relation.offset(offset).limit(per_page) end + # Returns metadata about the pagination. + # + # @return [Hash] The metadata hash containing page, per_page, total_count, and total_pages. def meta - { page: page, per_page: per_page, total_entries: total_entries, total_pages: total_pages } + { page: page, per_page: per_page, total_count: total_count, total_pages: total_pages } end private @@ -28,10 +40,10 @@ def offset end def total_pages - (total_entries.to_f / per_page).ceil + (total_count.to_f / per_page).ceil end - def total_entries - @total_entries ||= relation.offset(nil).limit(nil).count + def total_count + @total_count ||= relation.offset(nil).limit(nil).count end end diff --git a/app/use_cases/candidates/create_use_case.rb b/app/use_cases/candidates/create_use_case.rb index 0254f6b..de8e2ee 100644 --- a/app/use_cases/candidates/create_use_case.rb +++ b/app/use_cases/candidates/create_use_case.rb @@ -20,8 +20,8 @@ def candidate def candidate_params { - name: @params[:name], - email: @params[:email], + name: @params[:name], + email: @params[:email], birthdate: @params[:birthdate] } end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index bfceac4..da88871 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -17,14 +17,14 @@ config.openapi_specs = { 'v1/swagger.yaml' => { openapi: '3.0.1', - info: { - title: 'API V1', + info: { + title: 'API V1', version: 'v1' }, - paths: {}, + paths: {}, servers: [ { - url: 'http://localhost:3000', + url: 'http://localhost:3000', description: 'The local server' } ] From 17ca29a5a9c776194285732f9d3572b2a189e8a1 Mon Sep 17 00:00:00 2001 From: Andre Gomes Date: Fri, 9 Feb 2024 17:12:19 -0300 Subject: [PATCH 4/5] feat: add candidates listing endpoint --- .../api/v1/candidates_controller.rb | 13 +++++ app/models/candidate.rb | 4 ++ config/routes.rb | 2 +- .../api/v1/candidates_request_spec.rb | 48 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/candidates_controller.rb b/app/controllers/api/v1/candidates_controller.rb index 6a2f58c..292e2b4 100644 --- a/app/controllers/api/v1/candidates_controller.rb +++ b/app/controllers/api/v1/candidates_controller.rb @@ -3,6 +3,19 @@ module Api module V1 class CandidatesController < ApiController + def index + paginated_candidates = PaginateService.new( + relation: Candidate.all, + page: params[:page]&.to_i, + per_page: params[:per_page]&.to_i + ) + + render json: { + candidates: paginated_candidates.call, + meta: paginated_candidates.meta + } + end + def create candidate = Candidates::CreateUseCase.new(candidate_params).call return head :unprocessable_entity unless candidate diff --git a/app/models/candidate.rb b/app/models/candidate.rb index 7d6735d..232e6fb 100644 --- a/app/models/candidate.rb +++ b/app/models/candidate.rb @@ -1,4 +1,8 @@ # frozen_string_literal: true class Candidate < ApplicationRecord + def as_json(options = {}) + serializable_fields = %i[id name email birthdate] + super(options.merge(only: serializable_fields)) + end end diff --git a/config/routes.rb b/config/routes.rb index 9410a06..3e409ec 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,7 +6,7 @@ namespace :api do namespace :v1 do - resources :candidates, only: %i[create] + resources :candidates, only: %i[create index] end end diff --git a/spec/requests/api/v1/candidates_request_spec.rb b/spec/requests/api/v1/candidates_request_spec.rb index dfecf85..003c8d3 100644 --- a/spec/requests/api/v1/candidates_request_spec.rb +++ b/spec/requests/api/v1/candidates_request_spec.rb @@ -1,6 +1,54 @@ # frozen_string_literal: true RSpec.describe 'Api::V1::Candidates' do + describe 'GET /' do + subject(:get_candidates) { get '/api/v1/candidates', params: params } + + let(:params) { {} } + + it 'returns http success' do + get_candidates + expect(response).to have_http_status(:success) + end + + it 'returns a list of candidates' do + candidates = create_list(:candidate, 2) + get_candidates + expect(response.parsed_body).to eq({ + candidates: [{ + id: candidates.first.id, + name: candidates.first.name, + email: candidates.first.email, + birthdate: candidates.first.birthdate.to_s + }, { + id: candidates.second.id, + name: candidates.second.name, + email: candidates.second.email, + birthdate: candidates.second.birthdate.to_s + }], + meta: { page: 1, per_page: 20, total_pages: 1, total_count: 2 } + }.deep_stringify_keys) + end + + context 'with pagination' do + let(:params) { { page: 2, per_page: 2 } } + + it 'returns only paginated elements' do + candidates = create_list(:candidate, 3) + get_candidates + expect(response.parsed_body).to eq({ + candidates: [{ + id: candidates.third.id, + name: candidates.third.name, + email: candidates.third.email, + birthdate: candidates.third.birthdate.to_s + }], + meta: { page: 2, per_page: 2, total_pages: 2, total_count: 3 } + }.deep_stringify_keys) + end + end + end + describe 'POST /create' do subject(:create_candidate) { post '/api/v1/candidates', params: params } From 1e653818773752ed4d3aa9377aaed0d60917aff3 Mon Sep 17 00:00:00 2001 From: Andre Gomes Date: Fri, 9 Feb 2024 17:12:37 -0300 Subject: [PATCH 5/5] chore: update candidates Swagger documentation --- .../api/v1/candidates_request_swagger_spec.rb | 46 +++++++++++++++++-- swagger/v1/swagger.yaml | 46 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/spec/requests/api/v1/candidates_request_swagger_spec.rb b/spec/requests/api/v1/candidates_request_swagger_spec.rb index 8dcebf5..883cf4a 100644 --- a/spec/requests/api/v1/candidates_request_swagger_spec.rb +++ b/spec/requests/api/v1/candidates_request_swagger_spec.rb @@ -4,17 +4,55 @@ describe 'Candidates API' do path '/api/v1/candidates' do + get 'Retrieves a list of candidates' do + tags 'Candidates' + produces 'application/json' + parameter name: :page, in: :query, type: :integer + parameter name: :per_page, in: :query, type: :integer + + response '200', 'Candidates list' do + schema type: :object, + properties: { + candidates: { + type: :array, + items: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + email: { type: :string }, + birthdate: { type: :string, format: :date } + } + } + }, + meta: { + type: :object, + properties: { + page: { type: :integer }, + per_page: { type: :integer }, + total_pages: { type: :integer }, + total_count: { type: :integer } + } + } + } + + let(:page) { 1 } + let(:per_page) { 20 } + run_test! + end + end + post 'Creates a candidate' do tags 'Candidates' consumes 'application/json' parameter name: :candidate, in: :body, schema: { - type: :object, + type: :object, properties: { - name: { type: :string, example: 'Andre' }, - email: { type: :string, format: :email, example: 'andre@haistack.ai' }, + name: { type: :string, example: 'Andre' }, + email: { type: :string, format: :email, example: 'andre@haistack.ai' }, birthdate: { type: :string, format: :date, example: '1992-04-27' } }, - required: %w[name email birthdate] + required: %w[name email birthdate] } response '201', 'Candidate created' do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index c9cd682..8e74e34 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -5,6 +5,52 @@ info: version: v1 paths: "/api/v1/candidates": + get: + summary: Retrieves a list of candidates + tags: + - Candidates + parameters: + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + responses: + '200': + description: Candidates list + content: + application/json: + schema: + type: object + properties: + candidates: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + birthdate: + type: string + format: date + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total_pages: + type: integer + total_count: + type: integer post: summary: Creates a candidate tags: