From 79e7091dd5065f4b5717b292353fdbaa8927865d Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Fri, 19 Nov 2021 00:30:07 +0000 Subject: [PATCH] rnters backend-challenge Tiago Santos --- README.md | 79 ++++++++++++-------- app/controllers/api/v1/users_controller.rb | 74 ++++++++++++++++++ app/jobs/application_job.rb | 7 ++ app/jobs/update_user_job.rb | 8 ++ app/models/api_key.rb | 13 ++++ app/models/user.rb | 18 +++++ config/locales/en.yml | 4 + config/routes.rb | 3 +- db/migrate/20211115113315_create_users.rb | 12 +++ db/migrate/20211118161645_create_api_keys.rb | 11 +++ db/seeds.rb | 3 + spec/factories/api_keys.rb | 5 ++ spec/request/user_spec.rb | 36 ++++++++- spec/routing/users_routing_spec.rb | 22 ++++++ 14 files changed, 260 insertions(+), 35 deletions(-) create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/update_user_job.rb create mode 100644 app/models/api_key.rb create mode 100644 app/models/user.rb create mode 100644 db/migrate/20211115113315_create_users.rb create mode 100644 db/migrate/20211118161645_create_api_keys.rb create mode 100644 spec/factories/api_keys.rb create mode 100644 spec/routing/users_routing_spec.rb diff --git a/README.md b/README.md index 304db5b..04f856a 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,62 @@ # Rnters - Backend Code Challenge -If you are reading this, you probably have interviewed or chatted with someone on the team at Rnters. This is our standard "toy" project we normally like to work on together to see how you think about problems, model them, and make decisions. If you stumbled upon this project randomly and want to give it a shot, please feel free to fork the project and hack away. We would love to see what you come up with. -An initial version of this project should be doable in well under 2 hours, but has many facets that could be improved beyond that inital cut. And although we are providing you with most of the boilerplate so you can focus on the requested functionalities, it has many facets that could be improved beyond that initial cut. +## Creation of users -## Challenge -We are looking for an API-based application that exposes an API entry to create users with a first and last name. The twist - after 30seconds that user will grow a mustache, and the 🥸 emoji will be added to his last name. E.g. if I create the user "Quim Barreiros", after 30 seconds I will see that his name is "Quim Barreiros 🥸". Thats it! -#### Users -User table should have the following attributes: -- `first_name` -- `last_name` -- `admin` +I've created a migration in order to create the users table, with the attributes "first_name:string", "last_name:string", "admin:boolean" and the timestamps, which could be useful in a real-world application ("created_at:datetime" and "updated_at:datetime"). -#### API -Should have the following endpoint: -- `POST /api/v1/users` with the params `first_name` and `last_name` -- Should return `202 (Created)` if successful and the appropriate error otherwise +The defined routes (resources: users) are inside the namespaces "api" and "v1"... The available routes are constructed to allow us creating, showing the all the records and showing specific records. -## How to run -You'll need to have [Docker installed](https://docs.docker.com/get-docker/). Fork and clone this repo into your computer. +It was necessary to create a model referring to the users, which was created under the name "User". The model has validations, with the attributes "first_name" and "last_name" not being allowed to be blank. -After that you can simply run the web version so you can use curl: `docker-compose up` +It also includes attr_accessible, defining which attributes are permitted to be defined when posting the data. -Or you can run the console version: `docker-compose run --rm console` +In the controller, the action "create" defines a new user, based upon the "user_params", which is a function that points to the model's attr_accessible and allows that specific attributes to be defined. +Íf the user is successfully saved, it will be rendered json, with the "created" status coming as a response. -## How to test -We provide you with a simple test by running: -``` -$ docker-compose run --rm console -> rspec spec/request/user_spec.rb -``` +Otherwise, the response will be a bad request (for example, a user like {first_name: '', last_name: ''} is a bad request). -You can also run the environment and curl against it -``` -$ docker-compose up web -# in another terminal -$ curl -v -X POST 'http://127.0.0.1:8000/api/v1/users' -d '{"first_name": "Quim", "last_name": "Barreiros" }' -H "Content-Type: application/json" -``` +## Getting all the users -You can also check sidekiq admin through: -`http://127.0.0.1:8000/sidekiq` \ No newline at end of file +In order to test the creation and update after 30 seconds of all the users, I've created another endpoint, (get '/api/v1/users'). + + +Inside the controller's action, all the users are gathered (by calling the function "get_users") and used to be rendered as JSON. The status is 200, if any records are found. + + +## Getting a specific user + +This API also allows us to get a specific user, via the following endpoint (get '/api/v1/users/:id'). + +Inside the controller, the function "get_user" is called before any action occurs. It's not called individually inside "show" because, in a more extensive API (that would contain actions like updating and destroying records) it would also be used, so in this way we don't have the need to call it several times. + + + + +## Adding a moustache after 30 seconds +As it is a feature wanted to each record only one time, I felt I needed to do something after the creation of each instance. So, I've included in the model a function under the name "add_moustache" that is fired when a record is created (after_create). I've built an asynchrounous job, that waits 30 seconds after the each creation event to be performed. The job goes under the name "UpdateUserJob" and simply updates the user's last name, adding a moustache after it. + + +## Securing the API + +I've added another class ("ApiKey") with the attribute "access_token:string" and the timestamps being the attributes added. + +The objective was to build an authentication token, so I could secure the API in order to prevent access to everyone except those who have a valid token. + +Inside the corresponding model, there is a private method named "generate_access_token", in which a random hexadecimal string is generated. It has a condition to stop the generation, that has the objective of avoiding repeated tokens. + +Then, in the controller (commented) there is a function ("restrict_access") called before any action is taken. Inside the function, it is compared the header 'Authorization-token' with the registers in the database and then if the token doesn't exist in the DB, the sent status is 401 (unauthorized). + + +Another (simplistic) way of securing the API is also commented in the controller with the instruction "http_basic_authenticate_with name: 'rnters', password: 'sign€d'", which forces us to login as that user and to know that password in order to access the API functionalities. + + +## Notes + +The templates for the user updating and destroying are also included in the controller, so if we wanted to add those actions we only would need to uncomment them. + + +The docker-compose file had a problem in the "worker" container, caused by the lack of a defined "working_dir". \ No newline at end of file diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 0000000..59cec72 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,74 @@ +class Api::V1::UsersController < ActionController::Base + #before_action :restrict_access + before_action :get_user, only: [:show] + + #http_basic_authenticate_with name: 'rnters', password: 'sign€d' + # GET /users + def index + get_users + #poderia ser utilizado serializer + render json: @users, only: [:first_name, :last_name, :admin] + + end + + # GET /users/1 + def show + render json: @user, only: [:first_name, :last_name, :admin] + end + + # POST /users + def create + @user = User.new(user_params) + + + if @user.save + render json: @user, only: [:first_name, :last_name, :admin], status: :created + else + # status 422 + render json: @user.errors, status: :bad_request + end + end + + # PATCH/PUT /users/1 + #def update + # if @user.update(user_params) + # render json: @user, only: [:first_name, :last_name] + #else + # render json: @user.errors, status: :bad_request + # end + #end + + # DELETE /users/1 + #def destroy + # @user.destroy + + #head :no_content + #end + + private + def get_user + @user = User.find_by(id: params[:id]) + + if @user.nil? + render json: {message: I18n.t('user_not_found')}, status: :not_found + end + end + + + # def restrict_access + # token = request.headers['Autorization-token'] + # if !ApiKey.exists?(access_token: token) + # render json: {message: I18n.t('invalid_token')}, status: :unauthorized + # end + # end + + # Only allow a list of trusted parameters through. + def user_params + params.permit(*User.attr_accessible) + end + + def get_users + + @users = User.all + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/update_user_job.rb b/app/jobs/update_user_job.rb new file mode 100644 index 0000000..e7a1585 --- /dev/null +++ b/app/jobs/update_user_job.rb @@ -0,0 +1,8 @@ +# Job assíncrono para criar o moustache +class UpdateUserJob < ApplicationJob + queue_as :default + + def perform(user) + user.update(last_name: "#{user.last_name} 🥸") + end +end \ No newline at end of file diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..ae28c42 --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,13 @@ +class ApiKey < ApplicationRecord + before_create :generate_access_token + + private + + def generate_access_token + begin + #gerar o token + self.access_token = SecureRandom.hex + #se for repetido, não criar um novo + end while self.class.exists?(access_token: access_token) + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..c99620f --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,18 @@ +class User < ApplicationRecord + # não permitir first_name = '' e last_name = '' + validates :first_name, presence: true, allow_blank: false + validates :last_name, presence: true, allow_blank: false + after_create :add_moustache + + def self.attr_accessible + [ + :first_name, :last_name, :admin + ] + end + + private + + def add_moustache + UpdateUserJob.set(wait: 30.seconds).perform_later(self) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index b2dfb2b..2736df8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,3 +31,7 @@ en: hi: "Hi" + user_not_found: "User not found" + no_access_token: "No access token" + no_records_found: "No records found" + invalid_token: "Invalid token" \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 37bd5b6..6b6d1c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,11 +3,12 @@ mount Sidekiq::Web => '/sidekiq' - namespace :api, defaults: { format: 'json' }, constraints: { format: 'json' } do + namespace :api, defaults: { format: :json }, constraints: { format: :json } do namespace :v1 do # returns API status # only used for tests atm get 'ping', to: 'ping#ping' + resources :users, :only => [:index, :show, :create] end end end diff --git a/db/migrate/20211115113315_create_users.rb b/db/migrate/20211115113315_create_users.rb new file mode 100644 index 0000000..d2fea92 --- /dev/null +++ b/db/migrate/20211115113315_create_users.rb @@ -0,0 +1,12 @@ +class CreateUsers < ActiveRecord::Migration[6.1] + def change + if !table_exists? 'users' + create_table :users do |t| + t.string :first_name + t.string :last_name + t.boolean :admin, :default => false + t.timestamps + end + end + end +end diff --git a/db/migrate/20211118161645_create_api_keys.rb b/db/migrate/20211118161645_create_api_keys.rb new file mode 100644 index 0000000..e6c0d56 --- /dev/null +++ b/db/migrate/20211118161645_create_api_keys.rb @@ -0,0 +1,11 @@ +class CreateApiKeys < ActiveRecord::Migration[6.1] + def change + if !table_exists? 'api_keys' + create_table :api_keys do |t| + t.string :access_token, :unique => true + + t.timestamps + end + end + end +end diff --git a/db/seeds.rb b/db/seeds.rb index f3a0480..25c6b4d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,3 +5,6 @@ # # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) # Character.create(name: 'Luke', movie: movies.first) +ApiKey.create! + +#User.create!(first_name: 'Tiago', last_name: 'Santos', admin: true) \ No newline at end of file diff --git a/spec/factories/api_keys.rb b/spec/factories/api_keys.rb new file mode 100644 index 0000000..a9d0aad --- /dev/null +++ b/spec/factories/api_keys.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :api_key do + access_token { "MyString" } + end +end diff --git a/spec/request/user_spec.rb b/spec/request/user_spec.rb index 6a1fa43..b80579b 100644 --- a/spec/request/user_spec.rb +++ b/spec/request/user_spec.rb @@ -1,9 +1,9 @@ RSpec.describe 'User', type: :request do - describe "PUT /api/v1/questions/:id" do + describe "post /api/v1/users" do it 'returns success' do post "/api/v1/users", params: { first_name: 'Quim', last_name: 'Barreiros', admin: true } - + #created expect(response.status).to eq(201) # .... @@ -12,8 +12,38 @@ it 'returns error' do post "/api/v1/users", params: { first_name: '', last_name: '', admin: true } - expect(response.status).to eq(422) + expect(response.status).to eq(400) + # .... + end + end + + describe "get /api/v1/users/1" do + + it 'returns success' do + get "/api/v1/users/1" + + expect(response.status).to eq(200) # .... + + end + + it 'returns error' do + get "/api/v1/users/1" + + expect(response.status).to eq(404) + # .... + end + end + + + describe "get /api/v1/users" do + + it 'returns success' do + get "/api/v1/users" + + expect(response.status).to eq(200) + # .... + end end end \ No newline at end of file diff --git a/spec/routing/users_routing_spec.rb b/spec/routing/users_routing_spec.rb new file mode 100644 index 0000000..e12f335 --- /dev/null +++ b/spec/routing/users_routing_spec.rb @@ -0,0 +1,22 @@ +require "rails_helper" + +RSpec.describe Api::V1::UsersController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "api/v1/users").to route_to("api/v1/users#index", :format => :json) + end + + it "routes to #show" do + expect(get: "api/v1/users/1").to route_to("api/v1/users#show", id: "1", :format => :json) + end + + + it "routes to #create" do + expect(post: "api/v1/users").to route_to("api/v1/users#create", :format => :json) + end + + #it "routes to #destroy" do + # expect(delete: "/users/1").to route_to("users#destroy", id: "1") + #end + end +end