This app is a Rails backend API for a job tracking CRM tool.
Rails 7.1.5 Ruby 3.2.2
bundle install
rails db:create
rails db:migrate
rails db:seed
This app will run on port 3001 locally.
This app uses RSpec for testing.
bundle exec rspec
New users require a unique email address and a matching password and password confirmation.
Request:
POST /api/v1/users
Body:
{
"name": "John Doe",
"email": "[email protected]",
"password": "password",
"password_confirmation": "password"
}
Successful Response:
Status: 201 Created
Body: {
"data": {
"id": "4",
"type": "user",
"attributes": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
Error Responses:
Status: 400 Bad Request
Body: {
"message": "Email has already been taken",
"status": 400
}
Status: 400 Bad Request
Body: {
"message": "Password confirmation doesn't match Password",
"status": 400
}
Request:
GET /api/v1/users
Successful Response:
Status: 200 OK
Body: {
"data": [
{
"id": "1",
"type": "user",
"attributes": {
"name": "Danny DeVito",
"email": "danny_de_v"
}
},
...
{
"id": "4",
"type": "user",
"attributes": {
"name": "John Doe",
"email": "[email protected]"
}
}
]
}
Request:
GET /api/v1/users/:id
Successful Response:
Status: 200 OK
Body: {
"data": {
"id": "3",
"type": "user",
"attributes": {
"name": "Lionel Messi",
"email": "futbol_geek"
}
}
}
Request:
PUT /api/v1/users/:id
Body:
{
"name": "John Doe",
"email": "[email protected]",
"password": "password",
"password_confirmation": "password"
}
Successful Response:
Status: 200 OK
Body: {
"data": {
"id": "4",
"type": "user",
"attributes": {
"name": "Nathan Fillon",
"email": "firefly_captian"
}
}
}
Error Responses:
Status: 400 Bad Request
Body: {
"message": "Email has already been taken",
"status": 400
}
Status: 400 Bad Request
Body: {
"message": "Password confirmation doesn't match Password",
"status": 400
}
Request:
POST /api/v1/sessions
Body:
{
"email": "[email protected]",
"password": "password"
}
Successful Response:
Status: 200 OK
Body: {
"data": {
"id": "4",
"type": "user",
"attributes": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
Error Response:
Status: 401 Unauthorized
Body: {
"message": "Invalid login credentials",
"status": 401
}
Request:
GET /api/v1/users/:user_id/job_applications
Headers:
{
"Authorization": "Bearer <your_token_here>"
}
Successful Response:
Status: 200
{
"data": [
{
"id": "1",
"type": "job_application",
"attributes": {
"position_title": "Jr. CTO",
"date_applied": "2024-10-31",
"status": 1,
"notes": "Fingers crossed!",
"job_description": "Looking for Turing grad/jr dev to be CTO",
"application_url": "www.example.com",
"contact_information": "[email protected]",
"company_id": 1
}
},
{
"id": "3",
"type": "job_application",
"attributes": {
"position_title": "Backend Developer",
"date_applied": "2024-08-20",
"status": 2,
"notes": "Had a technical interview, awaiting decision.",
"job_description": "Developing RESTful APIs and optimizing server performance.",
"application_url": "https://creativesolutions.com/careers/backend-developer",
"contact_information": "[email protected]",
"company_id": 3
}
}
]
}
Error Response if no token provided:
Status: 401 Unauthorized
Body: {
"message": "Invalid login credentials",
"status": 401
}
Request:
POST /api/v1/users/:user_id/job_applications
Headers:
{
"Authorization": "Bearer <your_token_here>"
}
Body
Body: {
{
position_title: "Jr. CTO",
date_applied: "2024-10-31",
status: 1,
notes: "Fingers crossed!",
job_description: "Looking for Turing grad/jr dev to be CTO",
application_url: "www.example.com",
contact_information: "[email protected]",
company_id: id_1
}
}
Successful Response:
Status: 200
{:data=>
{:id=>"4",
:type=>"job_application",
:attributes=>
{:position_title=>"Jr. CTO",
:date_applied=>"2024-10-31",
:status=>1,
:notes=>"Fingers crossed!",
:job_description=>"Looking for Turing grad/jr dev to be CTO",
:application_url=>"www.example.com",
:contact_information=>"[email protected]",
:company_id=>35}}
}
Unsuccessful Response:
{:message=>"Company must exist and Position title can't be blank", :status=>400}
Request:
GET /api/v1/users/:user_id/job_applications/:job_application_id
Successful Response:
Status: 200 OK
{:data=>
{:id=>"4",
:type=>"job_application",
:attributes=>
{:position_title=>"Jr. CTO",
:date_applied=>"2024-10-31",
:status=>1,
:notes=>"Fingers crossed!",
:job_description=>"Looking for Turing grad/jr dev to be CTO",
:application_url=>"www.example.com",
:contact_information=>"[email protected]",
:company_id=>35}}
}
Unsuccessful Response(job application does not exist OR belongs to another user):
{:message=>"Job application not found", :status=>404}
Unsuccessful Response(missing job application ID param):
{:message=>"Job application ID is missing", :status=>400}
If the user is not authenticated:
{
"message": "Unauthorized request",
"status": 401
}
Unsuccessful Response(pre-existing application for user):
{:message=>"Application url You already have an application with this URL", :status=>400}
Get login credentials
Request:
POST /api/v1/sessions
Body:
{
"email": "[email protected]",
"password": "password"
}
Successful Response:
Status: 200 OK
This response will have a token like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzM0MzUzMDJ9.O6FtfoVjcobUiBHfKmZNovtt57061ktlPx-UgIZFGaQ
Body: {
"data": {
"id": "4",
"type": "user",
"attributes": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
Error Response if no token provided:
Status: 401 Unauthorized
Body: {
"message": "Invalid login credentials",
"status": 401
}
Request:
post "/api/v1/users/userid/companies"
Add the bearer token to the auth tab in postman and will be able to create a company now for that specific user. Make sure to have the token for that user.
raw json body:
{
"name": "New Company",
"website": "www.company.com",
"street_address": "123 Main St",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"notes": "This is a new company."
}
Successful Response:
Status: 201 created
"data": {
"id": "1",
"type": "company",
"attributes": {
"name": "New Company",
"website": "www.company.com",
"street_address": "123 Main St",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"notes": "This is a new company."
}
}
Error response - missing params
Request:
{
"name": "",
"website": "amazon.com",
"street_address": "410 Terry Ave N",
"city": "Seattle",
"state": "WA",
"zip_code": "98109",
"notes": "E-commerce"
}
Response:
{
"message": "Name can't be blank",
"status": 422
}
Request:
GET /api/v1/users/userid/companies
Authorization: Bearer Token - put in token for user
Successful Response:
Body:{
{
"data": [
{
"id": "1",
"type": "company",
"attributes": {
"name": "Google",
"website": "google.com",
"street_address": "1600 Amphitheatre Parkway",
"city": "Mountain View",
"state": "CA",
"zip_code": "94043",
"notes": "Search engine"
}
},
{
"id": "2",
"type": "company",
"attributes": {
"name": "New Company122",
"website": "www.company.com",
"street_address": "122 Main St",
"city": "New York11",
"state": "NY11",
"zip_code": "10001111",
"notes": "This is a new company111."
}
}
]
}
}
User with no companies:
{
"data": [],
"message": "No companies found"
}
No token or bad token response
{
"error": "Not authenticated"
}
Get login credentials:
Refer to Companies "Get login credentials" above
Request:
GET /api/v1/users/:user_id/contacts
Authorization: Bearer Token - put in token for user
Successful Response:
{
"data": [
{
"id": "1",
"type": "contacts",
"attributes": {
"first_name": "John",
"last_name": "Smith",
"company": "Turing",
"email": "[email protected]",
"phone_number": "(123) 555-6789",
"notes": "Type notes here...",
"user_id": 4
}
},
{
"id": "2",
"type": "contacts",
"attributes": {
"first_name": "Jane",
"last_name": "Smith",
"company": "Turing",
"email": "[email protected]",
"phone_number": "(123) 555-6789",
"notes": "Type notes here...",
"user_id": 4
}
}
]
}
Successful response for users without saved contacts:
{
"data": [],
"message": "No contacts found"
}
New contacts require a unique first and last name. All other fields are optional.
Request:
POST /api/v1/users/:user_id/contacts
Authorization: Bearer Token - put in token for user
raw json body with all fields:
{
"contact": {
"first_name": "Jonny",
"last_name": "Smith",
"company_id": 1,
"email": "[email protected]",
"phone_number": "555-785-5555",
"notes": "Good contact for XYZ",
"user_id": 7
}
}
Successful Response:
Status: 201 created
{
"data": {
"id": "5",
"type": "contacts",
"attributes": {
"first_name": "Jonny",
"last_name": "Smith",
"company_id": 1,
"email": "[email protected]",
"phone_number": "555-785-5555",
"notes": "Good contact for XYZ",
"user_id": 7
}
}
}
401 Error Response if no token provided:
Status: 401 Unauthorized
Body: {
"message": "Invalid login credentials",
"status": 401
}
422 Error Response Unprocessable Entity: Missing Required Fields If required fields like first_name or last_name are missing:
Request:
POST /api/v1/users/:user_id/contacts
Authorization: Bearer Token - put in token for user
raw json body:
{
"contact": {
"first_name": "Jonny",
"last_name": ""
}
}
Error response - 422 Unprocessable Entity
{
"error": "Last name can't be blank"
}
Error response - invalid email format
Request:
{
"contact": {
"first_name": "Johnny",
"last_name": "Smith",
"email": "invalid-email"
}
}
Response: 422 Unprocessable Entity
{
"error": "Email must be a valid email address"
}
Error response - invalid phone number format
Request:
{
"contact": {
"first_name": "Johnny",
"last_name": "Smith",
"email": "invalid-email"
}
}
Response: 422 Unprocessable Entity
{
"error": "Phone number must be in the format '555-555-5555'"
}
-
Authentication is handled by the JWT(Json Web Token) Gem
-
User Roles is handled by the Rolify Gem
-
Authorization is enforced by the Pundit Gem
-
- Pull the latest changes to your branch
git pull origin main
resolve merge conflicts - Bundle Install and Migrate
bundle install
thenrails db:migrate
- Run
bundle exec rspec spec/
and note what requests tests are now failing - Refactor controllers, their associated _spec.rb files. Make policies for corresponding controllers (app/policies/_policy.rb) and test policies
- Use examples in UserPolicy, ApplicationPolicy, UsersController, ApplicationController and all corresponding spec files.
- Pull the latest changes to your branch
-
-
This branch introduces 2 new tables to our db schema - roles and users_roles that were generated by Rolify:
- roles table has name, resouce_type and resource_id. Name string is the name of the role and can either be ["admin"] OR ["user"]. Resource type string is an optional polymorphic association that specifies the type of resource the role applies to (company, job, contact).
- Whenever a user is created, they are automatically assigned a role[:name] of
["user"]
- Whenever a user is created, they are automatically assigned a role[:name] of
- users_roles is a join table for the many-to-many relationship between users and roles.
- roles table has name, resouce_type and resource_id. Name string is the name of the role and can either be ["admin"] OR ["user"]. Resource type string is an optional polymorphic association that specifies the type of resource the role applies to (company, job, contact).
-
-
Understanding JWT(Json Web Token) Gem
We now use JSON Web Tokens for user authentication. Here's what to know:
-
- Token-Based Authentication
- Upon login, a JWT is issued to the registered user.
- The token must be included in the Headers for every authenticated request in Postman.
- Authorization: Bearer <jwt_token>
- The token encodes a payload that contains:
- User's id
- User's role (either "admin" or "user")
- Tokens expiration time (24 hours after issue)
- Changes made in Codebase
- ApplicationController now has 2 new methods:
authenticate_user
ensures tokens validity and corresponds to a logged-in user.decoded_token(token)
to extract info from token- All controllers will inherit this logic.
- SessionsController
- Handles login and session creation
#create
action:- Verifies user's credentials
generate_token(payload)
generates an encoded JWT using above-mentioned payload- Sends the token back in the response
- ApplicationController now has 2 new methods:
- Token-Based Authentication
-
-
Understanding the Rolify Gem
Rolify simplifies the management of user roles by introducing a flexible, role-based system. This gem created the roles and users_roles tables.
-
- When a new user is created, they are automatically assigned the user role.
- Methods in user.rb created as querying and setting shortcuts:
assign_default_role
is called to set a user's role to:user
upon a new instance of User being created. All new users will get role :user.is_admin?
and/oris_user?
queries any user's roleset_role(role_name)
assigns a specific role to a user(e.guser1.set_role(:admin)
will set a user to the :admin role)
- For future scalability, polymorphic roles can be scoped to specific users. For example, we can add in a :moderator role in the future that would be an :admin for only the company table. Enabled by resource_type and resource_id fields in roles table.
-
- In your models where roles are needed (e.g., User, Company), use:
resourcify
at the top of the class(near validations/relations) to make the model "role-aware" and capable of interacting with Rolify - Use above-mentioned methods in user.rb to query and set user roles.
- In your models where roles are needed (e.g., User, Company), use:
-
- UsersController now leverages roles to restrict access. For example, only an :admin can view all users (subject to change, ofc)
- ApplicationPolicy Includes logic to ensure actions are authorized based on roles.
- Affects RSpec testing to test role-based permitted controller actions (see index_spec.rb ln:7 for an example)
-
-
Understanding the Pundit Gem
Pundit enforces authorization through policy classes and ensures that only authorized users can perform specific actions. To fully understand Pundit, you must understand what a policy is:
-
A policy is a PORO that defines the rules governing what actions a user is authorized to perform on a resource (e.g., User, Job, Company). Each policy corresponds to a model's controller and centralizes the access control logic for that resource.
-
I have set up UserPolicy for it's corresponding UsersController as a template for all to use. UserPolicy restricts which CRUD actions a user can use depending on that user's role. All of Users associated request spec files have also been refactored to pass and are a template for you.
-
- Policy Classes as mentioned above define an action a user can take
- stored in app/policies and inherit from ApplicationPolicy
- Policies are then passed to ApplicationController via
include Pundit::Authorization
ln:2. Remember that all controllers inherit from ApplicationController unless explicitly told not to.
- Authorization Logic
- Authorization is defined in methods corresponding to controller actions (e.g., create?, update?).
- ApplicationPolicy provides a default template where ALL actions are unauthorized by default.
- Use UserPolicy (and it's associated spec file for testing) as a template for making your controller's policies.
- Scoping
- Policies include a nested Scope class (see UserPolicy) to define which records a user can access when retrieving a collection.
- Policy Classes as mentioned above define an action a user can take
-
- Use the
authorize
method IN your controller action code blocks to enforce policy checks (see UsersController ln: 5, 15, 21, 28 for examples. Yes, it is THAT easy :)) - If a user is unauthorized, Pundit raises a
Pundit::NotAuthorizedError
in your console RSpec testing suite. - For testing, refer to user_policy_spec.rb as a template. Note that ln:3
subject { described_class }
subject == UserPolicy - Policies integrate with Rolify to check user roles
- Use the
- JWT secures the app by ensuring only authenticated users can access resources. - Rolify manages who can act (roles), while Pundit manages what they can do (authorization). - Refactoring controllers and policies is critical to aligning with this new system. - Ensure all tests are updated to reflect these changes, including controller specs and new policy tests. - It is up to you, as you develop to keep in mind what actions users are authorized to take. :Admin role users should be authorized to do anything.
-