refactor: request thread safety context

This commit is contained in:
sjdonado
2025-03-18 11:04:50 +01:00
parent b22381cb7f
commit fba2039efc
4 changed files with 126 additions and 100 deletions
+66 -67
View File
@@ -1,7 +1,4 @@
require "uuid" require "uuid"
require "user_agent_parser"
require "../lib/controller.cr"
module App::Controllers module App::Controllers
class LinkController < App::Lib::BaseController class LinkController < App::Lib::BaseController
@@ -9,17 +6,20 @@ module App::Controllers
include App::Lib include App::Lib
include App::Services include App::Services
ClickTracker.init def initialize(@env : HTTP::Server::Context)
ClickTracker.init
super(@env)
end
def create(env) def create
user = env.get("user").as(User) user = current_user
body = parse_body(env, ["url"]) body = parse_body(["url"])
url = body["url"].to_s url = body["url"].to_s
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1) query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
existing_link = Database.all(Link, query, preload: [:clicks]).first? existing_link = Database.all(Link, query, preload: [:clicks]).first?
if existing_link if existing_link
return link_response(existing_link) return render_json({"data" => App::Serializers::Link.new(existing_link)})
end end
link = Link.new link = Link.new
@@ -30,42 +30,41 @@ module App::Controllers
changeset = Database.insert(link) changeset = Database.insert(link)
if !changeset.valid? if !changeset.valid?
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors)) raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
end end
link.clicks = [] of App::Models::Click link.clicks = [] of App::Models::Click
link_response(link) render_json({"data" => App::Serializers::Link.new(link)}, 201)
end end
def redirect(env) def redirect
slug = env.params.url["slug"] slug = @env.params.url["slug"]
link = nil link_data = nil
Database.raw_query("SELECT id, slug, url FROM links WHERE slug = (?) LIMIT 1", slug) do |result| Database.raw_query("SELECT id, url FROM links WHERE slug = (?) LIMIT 1", slug) do |result|
if result.move_next if result.move_next
link = { link_data = {result.read(String), result.read(String)}
id: result.read(String),
url: result.read(String),
}
end end
end end
raise App::NotFoundException.new(env) if !link raise App::NotFoundException.new(@env) unless link_data
remote_address = env.request.headers["Cf-Connecting-Ip"]?.try(&.presence) || env.request.remote_address.try &.to_s remote_address = @env.request.headers["Cf-Connecting-Ip"]?.try(&.presence) || @env.request.remote_address.try &.to_s
user_agent_str = env.request.headers["User-Agent"]? || "Unknown" user_agent_str = @env.request.headers["User-Agent"]? || "Unknown"
client_ip = IpLookup.extract_ip(remote_address) || "Unknown" client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
env.response.status_code = 301 @env.response.status_code = 301
env.response.headers["Location"] = link.not_nil![:url] @env.response.headers["Connection"] = "close"
env.response.headers["X-Forwarded-For"] = client_ip
env.response.headers["User-Agent"] = user_agent_str
spawn track_click(env, link.not_nil![:id], client_ip, user_agent_str) @env.response.headers["Location"] = link_data[1]
@env.response.headers["X-Forwarded-For"] = client_ip
@env.response.headers["User-Agent"] = user_agent_str
spawn track_click(link_data[0], client_ip, user_agent_str)
end end
def list_all(env) def list_all
user = env.get("user").as(User) user = current_user
limit, cursor = pagination_params(env) limit, cursor = pagination_params
query = Database::Query.where(user_id: user.id.as(String)) query = Database::Query.where(user_id: user.id.as(String))
query = query.where("id < ?", cursor) if cursor query = query.where("id < ?", cursor) if cursor
@@ -75,32 +74,32 @@ module App::Controllers
return paginated_response(links, limit) { |link| App::Serializers::Link.new(link) } return paginated_response(links, limit) { |link| App::Serializers::Link.new(link) }
end end
def get(env) def get
user = env.get("user").as(User) user = current_user
link_id = env.params.url["id"] link_id = @env.params.url["id"]
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1) query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
link = Database.all(Link, query).first? link = Database.all(Link, query).first?
raise App::NotFoundException.new(env) if link.nil? raise App::NotFoundException.new(@env) if link.nil?
clicks_query = Database::Query.where(link_id: link_id.as(String)) clicks_query = Database::Query.where(link_id: link_id.as(String))
.order_by("id DESC") .order_by("id DESC")
.limit(100) .limit(100)
link.clicks = Database.all(Click, clicks_query) link.clicks = Database.all(Click, clicks_query)
link_response(link) render_json({"data" => App::Serializers::Link.new(link)})
end end
def list_clicks(env) def list_clicks
user = env.get("user").as(User) user = current_user
link_id = env.params.url["id"] link_id = @env.params.url["id"]
# Verify link exists and belongs to user # Verify link exists and belongs to user
link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1) link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
link = Database.all(Link, link_query).first? link = Database.all(Link, link_query).first?
raise App::NotFoundException.new(env) if link.nil? raise App::NotFoundException.new(@env) if link.nil?
limit, cursor = pagination_params(env) limit, cursor = pagination_params
query = Database::Query.where(link_id: link_id.as(String)) query = Database::Query.where(link_id: link_id.as(String))
query = query.where("id < ?", cursor) if cursor query = query.where("id < ?", cursor) if cursor
@@ -110,22 +109,22 @@ module App::Controllers
return paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) } return paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) }
end end
def update(env) def update
user = env.get("user").as(User) user = current_user
id = env.params.url["id"] id = @env.params.url["id"]
body = parse_body(env, ["url"]) body = parse_body(["url"])
new_url = body["url"].to_s new_url = body["url"].to_s
query = Database::Query.where(id: id).limit(1) query = Database::Query.where(id: id).limit(1)
link = Database.all(Link, query, preload: [:clicks]).first? link = Database.all(Link, query, preload: [:clicks]).first?
raise App::NotFoundException.new(env) if link.nil? raise App::NotFoundException.new(@env) if link.nil?
raise App::ForbiddenException.new(env) if link.user_id != user.id raise App::ForbiddenException.new(@env) if link.user_id != user.id
# Check for existing URL # Check for existing URL
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1) existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
if Database.all(Link, existing_query).first? if Database.all(Link, existing_query).first?
raise App::UnprocessableEntityException.new(env, { "url" => ["URL already exists"] }) raise App::UnprocessableEntityException.new(@env, { "url" => ["URL already exists"] })
end end
link.url = new_url link.url = new_url
@@ -133,31 +132,35 @@ module App::Controllers
changeset = Database.update(link) changeset = Database.update(link)
if !changeset.valid? if !changeset.valid?
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors)) raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
end end
link_response(link) render_json({"data" => App::Serializers::Link.new(link)})
end end
def delete(env) def delete
user = env.get("user").as(User) user = current_user
id = env.params.url["id"] id = @env.params.url["id"]
link = Database.get(Link, id) link = Database.get(Link, id)
raise App::NotFoundException.new(env) if !link raise App::NotFoundException.new(@env) if !link
raise App::ForbiddenException.new(env) if link.user_id != user.id raise App::ForbiddenException.new(@env) if link.user_id != user.id
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id) result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id)
if result.rows_affected == 0 if result.rows_affected == 0
raise App::UnprocessableEntityException.new(env, { "id" => ["Row delete failed"] }) raise App::UnprocessableEntityException.new(@env, { "id" => ["Row delete failed"] })
end end
env.response.status_code = 204 @env.response.status_code = 204
end end
private def track_click(env, link_id, client_ip, user_agent_str) private def current_user : User
source = env.params.query["utm_source"]? || "Direct" @env.get("user").as(User)
referer = env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source end
private def track_click(link_id, client_ip, user_agent_str)
source = @env.params.query["utm_source"]? || "Direct"
referer = @env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source
ClickTracker.track( ClickTracker.track(
link_id: link_id, link_id: link_id,
@@ -168,28 +171,24 @@ module App::Controllers
) )
end end
private def pagination_params(env) private def pagination_params
limit = (env.params.query["limit"]? || "100").to_i limit = (@env.params.query["limit"]? || "100").to_i
cursor = env.params.query["cursor"]? cursor = @env.params.query["cursor"]?
{limit, cursor} {limit, cursor}
end end
private def link_response(link)
{"data" => App::Serializers::Link.new(link)}.to_json
end
private def paginated_response(items, limit) private def paginated_response(items, limit)
has_more = items.size > limit has_more = items.size > limit
items = items[0...limit] if has_more items = items[0...limit] if has_more
next_cursor = has_more ? items.last.id : nil next_cursor = has_more ? items.last.id : nil
{ render_json({
"data" => items.map { |item| yield item }, "data" => items.map { |item| yield item },
"pagination" => { "pagination" => {
"has_more" => has_more, "has_more" => has_more,
"next" => next_cursor "next" => next_cursor
} }
}.to_json })
end end
end end
end end
+8 -5
View File
@@ -1,10 +1,13 @@
require "../lib/controller.cr" require "../lib/controller.cr"
module App::Controllers::Ping module App::Controllers
class Get < App::Lib::BaseController class PingController < App::Lib::BaseController
def call(env) def initialize(@env : HTTP::Server::Context)
response = {"data" => "pong"} super(@env)
response.to_json end
def ping
render_json({data: "pong"})
end end
end end
end end
+29 -13
View File
@@ -1,29 +1,45 @@
module App::Lib module App::Lib
abstract class BaseController abstract class BaseController
def map_changeset_errors(errors) protected getter env : HTTP::Server::Context
def initialize(@env : HTTP::Server::Context); end
# Convert changeset errors to API-friendly format
protected def map_changeset_errors(errors)
errors.reduce({} of String => Array(String)) do |memo, error| errors.reduce({} of String => Array(String)) do |memo, error|
memo[error[:field]] = memo[error[:field]]? || [] of String field = error[:field].to_s
memo[error[:field]] << error[:message] message = error[:message].to_s
memo[field] ||= [] of String
memo[field] << message
memo memo
end end
end end
def parse_body(env, fields) protected def parse_body(required_fields : Array(String) = [] of String)
json_params = env.params.json.to_h json_params = @env.params.json.try(&.to_h) || {} of String => JSON::Any
missing_fields = [] of String json_params = json_params.transform_values(&.to_s) # Convert JSON::Any to String
fields.each do |field| missing_fields = required_fields.reject { |field| json_params.has_key?(field) }
unless json_params.has_key?(field)
missing_fields << field
end
end
unless missing_fields.empty? unless missing_fields.empty?
error_message = missing_fields.map { |field| "#{field}: Required field" }.join(", ") error_message = missing_fields.join(", ") + " required"
raise App::BadRequestException.new(env, error_message) raise App::BadRequestException.new(@env, error_message)
end end
json_params json_params
end end
protected def render_json(data, status_code : Int32 = 200)
@env.response.status_code = status_code
@env.response.content_type = "application/json"
data.to_json
end
protected def param(key : String) : String
@env.params.url[key]
rescue KeyError
raise App::BadRequestException.new(@env, "Missing required parameter: #{key}")
end
end end
end end
+23 -15
View File
@@ -1,45 +1,53 @@
require "./controllers/**" require "./controllers/**"
module App module App
# CORS handling middleware
before_all do |env| before_all do |env|
env.response.headers["Access-Control-Allow-Origin"] = "*" if env.request.path.starts_with?("/api/")
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" env.response.headers["Access-Control-Allow-Origin"] = "*"
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key" env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
end
end end
after_all do |env| # Error handling middleware
env.response.content_type = "application/json" error 404 do |env|
{error: "Not Found"}.to_json
end end
error 500 do |env|
get "/api/ping" do |env| {error: "Internal Server Error"}.to_json
Controllers::Ping::Get.new.call(env)
end end
get "/:slug" do |env| get "/:slug" do |env|
Controllers::Link::Index.new.call(env) Controllers::LinkController.new(env).redirect
end
# namespace /api
get "/api/ping" do |env|
Controllers::PingController.new(env).ping
end end
get "/api/links" do |env| get "/api/links" do |env|
Controllers::Link::All.new.call(env) Controllers::LinkController.new(env).list_all
end end
get "/api/links/:id" do |env| get "/api/links/:id" do |env|
Controllers::Link::Get.new.call(env) Controllers::LinkController.new(env).get
end end
get "/api/links/:id/clicks" do |env| get "/api/links/:id/clicks" do |env|
Controllers::Link::Clicks.new.call(env) Controllers::LinkController.new(env).list_clicks
end end
post "/api/links" do |env| post "/api/links" do |env|
Controllers::Link::Create.new.call(env) Controllers::LinkController.new(env).create
end end
put "/api/links/:id" do |env| put "/api/links/:id" do |env|
Controllers::Link::Update.new.call(env) Controllers::LinkController.new(env).update
end end
delete "/api/links/:id" do |env| delete "/api/links/:id" do |env|
Controllers::Link::Delete.new.call(env) Controllers::LinkController.new(env).delete
end end
end end