diff --git a/README.md b/README.md index c2a41c2..3dff263 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,25 @@ This is the backend API repository for TurLink. TurLink is a link shortener app "id": "1", "type": "user", "attributes": { - "email": "user@example.com" + "email": "user@example.com", + "links": [ + { + "id": 1, + "original": "testlink.com", + "short": "tur.link/4a7c204baeacaf2c", + "user_id": 1, + "created_at": "2024-08-23T15:51:38.866Z", + "updated_at": "2024-08-23T15:51:38.866Z" + }, + { + "id": 2, + "original": "testlink.com", + "short": "tur.link/67c758fc", + "user_id": 1, + "created_at": "2024-08-23T15:53:08.573Z", + "updated_at": "2024-08-23T15:53:08.573Z" + }, + ] } } } @@ -89,3 +107,117 @@ This is the backend API repository for TurLink. TurLink is a link shortener app ] } ``` +### Shorten a Link +- **POST** `/api/v1/users/:id/links?link={original link}` + - Description: Creates a shortened link associated with an exisiting user + - Example Request: POST `https://turlink-be-53ba7254a7c1.herokuapp.com/users/1/links?link=testlink.com` + - Successful Response (200 OK): + ```json + { + "data": { + "id": "1", + "type": "link", + "attributes": { + "original": "testlink.com", + "short": "tur.link/4a7c204baeacaf2c", + "user_id": 1 + } + } + } + ``` + - Error Response (422 Unprocessable Entity) -- when original link isn't entered: + ```json + { + "errors": [ + { + "status": "unprocessable_entity", + "message": "Original can't be blank" + } + ] + } + ``` + - Error Response (404 Not Found) -- when user_id is not valid: + ```json + { + "errors": [ + { + "status": "unprocessable_entity", + "message": "User must exist" + } + ] + } + ``` + +### All Links for a User +- **GET** `/api/v1/users/:id/links` + - Description: Gets an index of all links for a user + - Example Request: GET `https://turlink-be-53ba7254a7c1.herokuapp.com/users/1/links` + - Successful Response (200 OK): + ```json + { + "data": { + "id": "1", + "type": "user", + "attributes": { + "email": "user@example.com", + "links": [ + { + "id": 1, + "original": "testlink.com", + "short": "tur.link/4a7c204baeacaf2c", + "user_id": 1, + "created_at": "2024-08-23T15:51:38.866Z", + "updated_at": "2024-08-23T15:51:38.866Z" + }, + { + "id": 2, + "original": "testlink.com", + "short": "tur.link/67c758fc", + "user_id": 1, + "created_at": "2024-08-23T15:53:08.573Z", + "updated_at": "2024-08-23T15:53:08.573Z" + }, + ] + } + } + } + ``` + - Error Response (404 Not Found) -- when user_id is not valid: + ```json + { + "errors": [ + { + "message": "Record not found" + } + ] + } + ``` + +### Return full link when short link is given +- **GET** `/api/v1/links?short={shortened link}` + - Description: Gets full link object when given shortened link + - Example Request: GET `https://turlink-be-53ba7254a7c1.herokuapp.com/links?short=tur.link/4a7c204baeacaf2c` + - Successful Response (200 OK): + ```json + { + "data": { + "id": "1", + "type": "link", + "attributes": { + "original": "testlink.com", + "short": "tur.link/4a7c204baeacaf2c", + "user_id": 1 + } + } + } + ``` + - Error Response (404 Not Found) -- when shortened link is not entered or does not exist: + ```json + { + "errors": [ + { + "message": "Record not found" + } + ] + } + ``` \ No newline at end of file diff --git a/app/controllers/api/v1/links_controller.rb b/app/controllers/api/v1/links_controller.rb new file mode 100644 index 0000000..0d51f9f --- /dev/null +++ b/app/controllers/api/v1/links_controller.rb @@ -0,0 +1,34 @@ +class Api::V1::LinksController < ApplicationController + + rescue_from ActiveRecord::RecordNotFound, with: :not_found_error + + def create + link = Link.create_new(params[:user_id], params[:link]) + if link.save + render json: LinkSerializer.new(link) + else + render json: ErrorSerializer.new(link, :unprocessable_entity).serialize, status: :unprocessable_entity + end + end + + def index + user = User.find(params[:user_id]) + render json: UserSerializer.new(user) + end + + def show + link = Link.find_by(short: params[:short]) + if link != nil + render json: LinkSerializer.new(link) + else + not_found_error + end + end + + private + + def not_found_error + render json: { errors: [{ message: 'Record not found' }] }, status: :not_found + end + +end \ No newline at end of file diff --git a/app/models/link.rb b/app/models/link.rb index cddb101..495cd9a 100644 --- a/app/models/link.rb +++ b/app/models/link.rb @@ -1,7 +1,18 @@ +require 'securerandom' + class Link < ApplicationRecord validates_presence_of :user_id validates_presence_of :original validates :short, uniqueness: true, presence: true belongs_to :user + + def self.create_new(user_id, original) + short = Link.create_short_link + Link.new(user_id: user_id, original: original, short: short) + end + + def self.create_short_link + "tur.link/#{SecureRandom.hex(4)}" + end end diff --git a/app/serializers/link_serializer.rb b/app/serializers/link_serializer.rb new file mode 100644 index 0000000..7e42c64 --- /dev/null +++ b/app/serializers/link_serializer.rb @@ -0,0 +1,4 @@ +class LinkSerializer + include JSONAPI::Serializer + attributes :original, :short, :user_id +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 2cae0fb..0f0673a 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,4 +1,4 @@ class UserSerializer include JSONAPI::Serializer - attributes :email + attributes :email, :links end diff --git a/config/routes.rb b/config/routes.rb index c2175b0..e0a08dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,8 +7,11 @@ namespace :api do namespace :v1 do - resources :users, only: %i[create] + resources :users, only: %i[create] do + resources :links, only: %i[create index] + end resources :sessions, only: %i[create] + resources :links, only: %i[index], action: :show end end end diff --git a/spec/models/link_spec.rb b/spec/models/link_spec.rb index 64bb40e..d399f32 100644 --- a/spec/models/link_spec.rb +++ b/spec/models/link_spec.rb @@ -15,4 +15,18 @@ describe 'relationships' do it { should belong_to :user } end + + describe 'class methods' do + describe 'create_new' do + it 'can create a new link' do + user1 = User.create(email: "user@example.com", password: "user123") + link = Link.create_new(user1.id, "long-link-example.com") + + expect(link).to be_a Link + expect(link.user_id).to eq(user1.id) + expect(link.original).to eq("long-link-example.com") + expect(link.short).to be_a String + end + end + end end diff --git a/spec/requests/api/v1/links_request_spec.rb b/spec/requests/api/v1/links_request_spec.rb new file mode 100644 index 0000000..0089a61 --- /dev/null +++ b/spec/requests/api/v1/links_request_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.describe 'links requests', type: :request do + describe 'POST /users/:id/links' do + it 'creates a new Link record with a shortened link' do + user1 = User.create(email: "user@example.com", password: "user123") + + post "/api/v1/users/#{user1.id}/links?link=long-link-example.com" + + expect(response).to be_successful + expect(response.status).to eq(200) + + link = JSON.parse(response.body, symbolize_names: true)[:data] + + expect(link).to be_a Hash + expect(link).to have_key :id + expect(link[:type]).to eq("link") + attributes = link[:attributes] + expect(attributes[:original]).to eq("long-link-example.com") + expect(attributes[:user_id]).to eq(user1.id) + expect(attributes[:short]).to be_a String + end + + describe 'sad path' do + it "will return 422 if link param is not entered" do + user1 = User.create(email: "user@example.com", password: "user123") + + post "/api/v1/users/#{user1.id}/links" + + expect(response).to_not be_successful + expect(response.status).to eq(422) + + error = JSON.parse(response.body, symbolize_names: true)[:errors][0] + + expect(error[:status]).to eq("unprocessable_entity") + expect(error[:message]).to eq("Original can't be blank") + end + + it "will return 404 if user_id does not exist" do + post "/api/v1/users/99999999/links?link=this-wont-work.com" + + expect(response).to_not be_successful + expect(response.status).to eq(422) + + error = JSON.parse(response.body, symbolize_names: true)[:errors][0] + + expect(error[:status]).to eq("unprocessable_entity") + expect(error[:message]).to eq("User must exist") + end + end + end + + describe 'GET /users/:id/links' do + it "retrieves the user object with all associated links as attributes" do + user1 = User.create(email: "user@example.com", password: "user123") + + post "/api/v1/users/#{user1.id}/links?link=long-link-example.com" + post "/api/v1/users/#{user1.id}/links?link=another-long-link.com" + + get "/api/v1/users/#{user1.id}/links" + + expect(response).to be_successful + expect(response.status).to eq(200) + + user = JSON.parse(response.body, symbolize_names: true)[:data] + + expect(user).to have_key :id + expect(user[:type]).to eq("user") + expect(user[:attributes][:email]).to eq("user@example.com") + links = user[:attributes][:links] + expect(links.count).to eq(2) + first_link = links[0] + expect(first_link).to have_key :id + expect(first_link[:original]).to eq("long-link-example.com") + expect(first_link[:short]).to be_a String + expect(first_link[:user_id]).to eq(user1.id) + end + + describe "sad path" do + it "will return 404 if user does not exist" do + get "/api/v1/users/999999/links" + + expect(response).to_not be_successful + expect(response.status).to eq(404) + + error = JSON.parse(response.body, symbolize_names: true)[:errors][0] + + expect(error[:message]).to eq("Record not found") + end + end + end + + describe 'GET /links' do + it "can return a the json for a link (including full url) when given shortened link" do + user1 = User.create(email: "user@example.com", password: "user123") + + post "/api/v1/users/#{user1.id}/links?link=long-link-example.com" + + link = JSON.parse(response.body, symbolize_names: true)[:data] + + short = link[:attributes][:short] + + get "/api/v1/links?short=#{short}" + + expect(response).to be_successful + expect(response.status).to eq(200) + + link2 = JSON.parse(response.body, symbolize_names: true)[:data] + + expect(link2).to have_key :id + expect(link2[:type]).to eq("link") + expect(link2[:attributes][:original]).to eq("long-link-example.com") + expect(link2[:attributes][:short]).to eq(short) + expect(link2[:attributes][:user_id]).to eq(user1.id) + end + + describe "sad path" do + it "will return a 404 if shortened link doesn't exist" do + get "/api/v1/links?short=tur.link/12345678" + + expect(response).to_not be_successful + expect(response.status).to eq(404) + + error = JSON.parse(response.body, symbolize_names: true)[:errors][0] + + expect(error[:message]).to eq("Record not found") + end + end + end +end \ No newline at end of file