diff --git a/.rubocop.yml b/.rubocop.yml index 76366c4..81f41a0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,3 +13,16 @@ Style/Documentation: RSpec/EmptyExampleGroup: Exclude: - "**/*swagger_spec.rb" + +RSpec/NestedGroups: + Max: 4 + +RSpec/ExampleLength: + Enabled: false + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table 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/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/app/services/paginate_service.rb b/app/services/paginate_service.rb new file mode 100644 index 0000000..b983615 --- /dev/null +++ b/app/services/paginate_service.rb @@ -0,0 +1,49 @@ +# 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 + + # 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 || 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_count: total_count, total_pages: total_pages } + end + + private + + attr_reader :relation, :page, :per_page + + def offset + (page - 1) * per_page + end + + def total_pages + (total_count.to_f / per_page).ceil + end + + 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/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 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 } 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/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 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' } ] 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: