- The user should not see any record he/she isn't allowed to view
- The user should not see relationships which he/she isn't allowed to view
- We should be able to invalidate a link
- We should be able to expire a link
- We should support sharing different types or records in the future
Create table shareables
Schema:
create_table :shareables do |t|
t.string :shareable_type, index: true
t.integer :shareadble_id
t.string :public_uid
t.bool :indexable
t.date :expiry, index: true
t.integer :created_by_id, index: true
t.timestamps
end
add_index :shareables, [:shareable_type, :shareadble_id], unique: true
add_foreign_key :shareables, :users, column: :created_by_id
example record
{
"shareable_type": "Offer",
"shareable_id": 123,
"public_uid": "77345736-b84a-4525-b1d4",
"expiry": null,
"indexable": true,
"created_by": 1,
"created_at": "1-1-2020T00:00:00"
}
- We don't use the actual IDs of records in any endpoint (allowing us to invalidate urls)
- We scope all "shared" or "public" features under an api namespace in which we can add logic without ever affecting the existing workflow (which is prone to breaking)
- We build this feature on top of API v2, reasons:
- We avoid using V1 Serializers (dangerous as they include all sorts of relationships the user might not be allowed to see)
- We want to move away from API v1 in the long run, so we would be future-proofing
- V2 Serializers are pretty lean by default, we want to keep it that way
All the endpoints/controllers under that route would be designed to use the "public_uid" instead of general IDs, and would not interfere with any part of the normal API
GET /api/v2/public/offers/77345736-b84a-4525-b1d4
{
"data": {
"id": "3",
"type": "offer",
"attributes": {
"notes": "test offer",
"state": "scheduled"
}
}
}
# models/concerns/share_support.rb
module ShareSupport
extend ActiveSupport::Concern
included do
scope :publicly_shared, -> {
# Exclude records which do not have a shared record
joins("LEFT JOIN shareables ON shareables.id = (?).id AND shareables.type = (?)", table_name, class.name.demodulize)
}
scope :publicly_indexed, -> {
publicly_shared.where("shareables.indexables = TRUE")
}
end
end
# models/item.rb
class Item < ApplicationRecord
include ShareSupport
...
end
# models/offer.rb
class Offer < ApplicationRecord
include ShareSupport
...
end
# api/v2/public/api_controller.rb
module Api
module V2
module Public
class ApiController < Api::V2::ApiController
before_action :preload_resource
skip_before_action :validate_token, only: [:index, :show] # Make reads public by default
def self.enable_resource_preload(type)
@@preload_type = type
end
private
#
# CanCan-style preloading using public ids
#
def preload_resource
return unless @@preload_type.present?
if params[:id].blank?
# collection
model = @@preload_type.to_s.camelize.constantize
instance_variable_set("@#{@@preload_type.pluralize}", model.publicly_indexed)
return;
end
# single item
id = params[:id]
share_record = Shareable.find_by(
shareable_type: @@preload_type,
public_uid: id
)
raise Goodcity::NotFoundError unless share_record.present?
instance_variable_set("@#{@@preload_type}", share_record.shareable)
end
end
end
end
end
# api/v2/public/offers_controller.rb
module Api
module V2
module Public
class OffersController < Api::V2::Public::ApiController
enable_resource_preload :offer
api :GET, '/v1/public/offers/77345736-b84a-4525-b1d4'
desc: "Fetch a public offer"
def show
render json: Api::V2::OfferSerializer.new(@offer, custom_options)
end
api :GET, '/v1/public/offers'
desc: "List publicly indexed offers"
def index
render json: Api::V2::OfferSerializer.new(Offer.publicly_indexed, custom_options)
end
api :GET, '/v1/public/offers/77345736-b84a-4525-b1d4/items'
desc: "Fetch a public offer's items'"
def items
render json: Api::V2::OfferSerializer.new(
@offer.items.publicly_shared,
custom_options
)
end
end
end
end
end
# ability.rb
can [:index, :show, :create], Message do |message|
Shareable.is_public(message.related_object) || (
@user_id != nil && !message.is_private && message.related_object&.created_by_id == @user_id
)
end